Skip to content

Commit 829ef71

Browse files
author
Adam Dayan
committed
Add all query command to export rules with full text
Introduces a new `tracey query all` command that returns every rule in a spec/impl along with its populated markdown body text. This is primarily designed for machine-readable export via `--json` (e.g., syncing requirements to external trackers). To support this: - Bumped the daemon protocol version to 5. - Added `AllRequest` and `AllResponse` to the RPC protocol. - Modified `RuleRef` to optionally include raw rule text, ensuring existing lightweight commands (`uncovered`, `untested`) remain unaffected.
1 parent d813697 commit 829ef71

7 files changed

Lines changed: 274 additions & 1 deletion

File tree

crates/tracey-proto/src/lib.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub use tracey_api::*;
1414
/// Protocol version — bump this whenever any RPC method is added, removed, or changed.
1515
/// The daemon writes this into its PID file; connectors compare it before connecting
1616
/// to detect stale daemons running an incompatible build.
17-
pub const PROTOCOL_VERSION: u32 = 4;
17+
pub const PROTOCOL_VERSION: u32 = 5;
1818

1919
// ============================================================================
2020
// Request/Response types for the TraceyDaemon service
@@ -86,6 +86,29 @@ pub struct UntestedResponse {
8686
pub by_section: Vec<SectionRules>,
8787
}
8888

89+
/// Request for all-rules query
90+
#[derive(Debug, Clone, Facet)]
91+
#[facet(rename_all = "camelCase")]
92+
pub struct AllRequest {
93+
#[facet(default)]
94+
pub spec: Option<String>,
95+
#[facet(default)]
96+
pub impl_name: Option<String>,
97+
#[facet(default)]
98+
pub prefix: Option<String>,
99+
}
100+
101+
/// Response for all-rules query.
102+
/// Returns every rule in the spec/impl with its body text populated.
103+
#[derive(Debug, Clone, Facet)]
104+
#[facet(rename_all = "camelCase")]
105+
pub struct AllResponse {
106+
pub spec: String,
107+
pub impl_name: String,
108+
pub total_rules: usize,
109+
pub by_section: Vec<SectionRules>,
110+
}
111+
89112
/// Request for stale references query
90113
#[derive(Debug, Clone, Facet)]
91114
#[facet(rename_all = "camelCase")]
@@ -653,6 +676,9 @@ pub trait TraceyDaemon {
653676
/// Get untested rules (rules with impl but no verify references)
654677
async fn untested(&self, req: UntestedRequest) -> UntestedResponse;
655678

679+
/// Get every rule in a spec/impl, with body text populated.
680+
async fn all(&self, req: AllRequest) -> AllResponse;
681+
656682
/// Get stale references (code pointing to older rule versions)
657683
async fn stale(&self, req: StaleRequest) -> StaleResponse;
658684

crates/tracey/src/bridge/query.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,50 @@ impl QueryClient {
368368
self.with_config_banner(output).await
369369
}
370370

371+
/// List every rule in a spec/impl with body text.
372+
pub async fn all(&self, spec_impl: Option<&str>, prefix: Option<&str>) -> String {
373+
let (spec, impl_name) = match self.checked_spec_impl(spec_impl).await {
374+
Ok(values) => values,
375+
Err(error) => return self.with_config_banner(format!("Error: {error}")).await,
376+
};
377+
378+
let req = AllRequest {
379+
spec,
380+
impl_name,
381+
prefix: prefix.map(String::from),
382+
};
383+
384+
let output = match self.client.all(req).await {
385+
Ok(response) => {
386+
let mut output = format!(
387+
"{}/{}: {} rules total\n\n",
388+
response.spec, response.impl_name, response.total_rules
389+
);
390+
391+
for section in &response.by_section {
392+
if !section.rules.is_empty() {
393+
output.push_str(&format!("## {}\n", section.section));
394+
for rule in &section.rules {
395+
output.push_str(&format!(" - {}\n", rule.id));
396+
}
397+
output.push('\n');
398+
}
399+
}
400+
401+
output.push_str("---\n");
402+
output.push_str(&self.hint(
403+
"tracey query all",
404+
"tracey query to list all rules with body text",
405+
));
406+
407+
output
408+
}
409+
Err(e) => format!("Error: {e:?}"),
410+
};
411+
412+
self.with_config_banner(output).await
413+
}
414+
371415
/// Get code units without rule references
372416
pub async fn unmapped(&self, spec_impl: Option<&str>, path: Option<&str>) -> String {
373417
let (spec, impl_name) = match self.checked_spec_impl(spec_impl).await {

crates/tracey/src/daemon/client.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ impl DaemonClient {
123123
self.with_client(|c| async move { c.untested(req).await })
124124
.await
125125
}
126+
pub async fn all(
127+
&self,
128+
req: tracey_proto::AllRequest,
129+
) -> Result<tracey_proto::AllResponse, roam::RoamError> {
130+
self.with_client(|c| async move { c.all(req).await }).await
131+
}
126132
pub async fn stale(
127133
&self,
128134
req: tracey_proto::StaleRequest,

crates/tracey/src/daemon/service.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,44 @@ impl TraceyDaemon for TraceyService {
322322
}
323323
}
324324

325+
/// Get every rule with body text populated
326+
async fn all(&self, req: AllRequest) -> AllResponse {
327+
let data = self.inner.engine.data().await;
328+
let query = QueryEngine::new(&data);
329+
330+
let (spec, impl_name) =
331+
self.resolve_spec_impl(req.spec.as_deref(), req.impl_name.as_deref(), &data.config);
332+
333+
if let Some(result) = query.all(&spec, &impl_name, req.prefix.as_deref()) {
334+
AllResponse {
335+
spec: result.spec,
336+
impl_name: result.impl_name,
337+
total_rules: result.total_rules,
338+
by_section: result
339+
.by_section
340+
.into_iter()
341+
.map(|(section, rules)| SectionRules {
342+
section,
343+
rules: rules
344+
.into_iter()
345+
.map(|r| tracey_proto::RuleRef {
346+
id: r.id,
347+
text: r.text,
348+
})
349+
.collect(),
350+
})
351+
.collect(),
352+
}
353+
} else {
354+
AllResponse {
355+
spec,
356+
impl_name,
357+
total_rules: 0,
358+
by_section: vec![],
359+
}
360+
}
361+
}
362+
325363
/// Get stale references
326364
async fn stale(&self, req: StaleRequest) -> StaleResponse {
327365
let data = self.inner.engine.data().await;

crates/tracey/src/main.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,17 @@ enum QueryCommand {
246246
prefix: Option<String>,
247247
},
248248

249+
/// List every rule in a spec/impl with body text (designed for --json export)
250+
All {
251+
/// Spec/impl to query (e.g., "my-spec/rust"). Optional if only one exists.
252+
#[facet(args::named, default)]
253+
spec_impl: Option<String>,
254+
255+
/// Filter by rule ID prefix
256+
#[facet(args::named, default)]
257+
prefix: Option<String>,
258+
},
259+
249260
/// Show unmapped code units
250261
Unmapped {
251262
/// Spec/impl to query (e.g., "my-spec/rust"). Optional if only one exists.
@@ -508,6 +519,12 @@ async fn main() -> Result<()> {
508519
.await,
509520
false,
510521
),
522+
QueryCommand::All { spec_impl, prefix } => (
523+
query_client
524+
.all(spec_impl.as_deref(), prefix.as_deref())
525+
.await,
526+
false,
527+
),
511528
QueryCommand::Unmapped { spec_impl, path } => (
512529
query_client
513530
.unmapped(spec_impl.as_deref(), path.as_deref())
@@ -635,6 +652,35 @@ async fn query_json(qc: &bridge::query::QueryClient, query: QueryCommand) -> (St
635652
Err(e) => (json_error(&format!("{e:?}")), false),
636653
}
637654
}
655+
QueryCommand::All { spec_impl, prefix } => {
656+
let (spec, impl_name) = match spec_impl.as_deref() {
657+
Some(raw) => {
658+
let config = match qc.client.config().await {
659+
Ok(config) => config,
660+
Err(e) => {
661+
return (json_error(&format!("failed to load config: {e:?}")), false);
662+
}
663+
};
664+
match validate_spec_impl_selection(Some(raw), &config) {
665+
Ok(values) => values,
666+
Err(error) => return (json_error(&error), false),
667+
}
668+
}
669+
None => parse_spec_impl(None),
670+
};
671+
let req = AllRequest {
672+
spec,
673+
impl_name,
674+
prefix,
675+
};
676+
match qc.client.all(req).await {
677+
Ok(resp) => (
678+
facet_json::to_string_pretty(&resp).expect("JSON serialization failed"),
679+
false,
680+
),
681+
Err(e) => (json_error(&format!("{e:?}")), false),
682+
}
683+
}
638684
QueryCommand::Stale { spec_impl, prefix } => {
639685
let (spec, impl_name) = match spec_impl.as_deref() {
640686
Some(raw) => {

crates/tracey/src/server.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,42 @@ impl<'a> QueryEngine<'a> {
314314
})
315315
}
316316

317+
/// Get every rule for a spec/impl with body text populated.
318+
///
319+
/// Designed for machine-readable export (e.g. syncing requirements to an
320+
/// external tracker). Unlike `uncovered`/`untested`, this populates each
321+
/// `RuleRef.text` so callers do not need to issue a follow-up `rule()` call
322+
/// per rule.
323+
pub fn all(
324+
&self,
325+
spec: &str,
326+
impl_name: &str,
327+
prefix_filter: Option<&str>,
328+
) -> Option<AllResult> {
329+
let key: ImplKey = (spec.to_string(), impl_name.to_string());
330+
let forward = self.data.forward_by_impl.get(&key)?;
331+
332+
let rules: Vec<&ApiRule> = forward
333+
.rules
334+
.iter()
335+
.filter(|r| {
336+
prefix_filter
337+
.map(|p| r.id.base.to_lowercase().starts_with(&p.to_lowercase()))
338+
.unwrap_or(true)
339+
})
340+
.collect();
341+
342+
let by_section = group_rules_by_section_with_text(&rules);
343+
344+
Some(AllResult {
345+
spec: spec.to_string(),
346+
impl_name: impl_name.to_string(),
347+
total_rules: rules.len(),
348+
by_section,
349+
prefix_filter: prefix_filter.map(|s| s.to_string()),
350+
})
351+
}
352+
317353
/// Get stale references for a spec/impl, optionally filtered by rule ID prefix
318354
pub fn stale(
319355
&self,
@@ -531,10 +567,23 @@ pub struct UntestedResult {
531567
pub prefix_filter: Option<String>,
532568
}
533569

570+
#[derive(Debug, Clone)]
571+
pub struct AllResult {
572+
pub spec: String,
573+
pub impl_name: String,
574+
pub by_section: BTreeMap<String, Vec<RuleRef>>,
575+
pub total_rules: usize,
576+
pub prefix_filter: Option<String>,
577+
}
578+
534579
#[derive(Debug, Clone)]
535580
pub struct RuleRef {
536581
pub id: RuleId,
537582
pub impl_refs: Vec<ApiCodeRef>,
583+
/// Raw markdown body of the rule. Populated only when the caller asks for it
584+
/// (e.g. `all()` for machine-readable export); `None` for the lightweight
585+
/// summary commands (`uncovered`, `untested`).
586+
pub text: Option<String>,
538587
}
539588

540589
#[derive(Debug, Clone)]
@@ -645,6 +694,17 @@ impl RuleInfo {
645694
// ============================================================================
646695

647696
fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>> {
697+
group_rules_by_section_inner(rules, false)
698+
}
699+
700+
fn group_rules_by_section_with_text(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>> {
701+
group_rules_by_section_inner(rules, true)
702+
}
703+
704+
fn group_rules_by_section_inner(
705+
rules: &[&ApiRule],
706+
include_text: bool,
707+
) -> BTreeMap<String, Vec<RuleRef>> {
648708
let mut result: BTreeMap<String, Vec<RuleRef>> = BTreeMap::new();
649709

650710
for rule in rules {
@@ -657,6 +717,11 @@ fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>>
657717
result.entry(section).or_default().push(RuleRef {
658718
id: rule.id.clone(),
659719
impl_refs: rule.impl_refs.clone(),
720+
text: if include_text {
721+
Some(rule.raw.clone())
722+
} else {
723+
None
724+
},
660725
});
661726
}
662727

crates/tracey/tests/integration_tests.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,54 @@ async fn test_uncovered_with_prefix_filter() {
182182
}
183183
}
184184

185+
#[tokio::test]
186+
async fn test_all_returns_every_rule_with_text() {
187+
let service = create_test_service().await;
188+
let req = AllRequest {
189+
spec: Some("test".to_string()),
190+
impl_name: Some("rust".to_string()),
191+
prefix: None,
192+
};
193+
194+
let response = rpc(service.client.all(req).await);
195+
196+
assert_eq!(response.spec, "test");
197+
assert_eq!(response.impl_name, "rust");
198+
199+
// spec.md defines 8 rules across 3 sections.
200+
let total: usize = response.by_section.iter().map(|s| s.rules.len()).sum();
201+
assert_eq!(total, 8, "expected 8 rules from fixture spec.md");
202+
assert_eq!(response.total_rules, 8);
203+
204+
// Every rule must have a non-empty body — this is the whole point of `all`.
205+
for section in &response.by_section {
206+
for rule in &section.rules {
207+
let text = rule
208+
.text
209+
.as_ref()
210+
.unwrap_or_else(|| panic!("rule {} missing text", rule.id));
211+
assert!(!text.is_empty(), "rule {} has empty text", rule.id);
212+
}
213+
}
214+
215+
// Content fidelity: a known rule's body must match the spec markdown.
216+
let login = response
217+
.by_section
218+
.iter()
219+
.flat_map(|s| &s.rules)
220+
.find(|r| r.id.base == "auth.login")
221+
.expect("auth.login should be present");
222+
assert!(
223+
login
224+
.text
225+
.as_ref()
226+
.unwrap()
227+
.contains("valid credentials"),
228+
"auth.login body should contain 'valid credentials', got: {:?}",
229+
login.text
230+
);
231+
}
232+
185233
#[tokio::test]
186234
async fn test_untested_returns_rules() {
187235
let service = create_test_service().await;

0 commit comments

Comments
 (0)