diff --git a/crates/tracey-proto/src/lib.rs b/crates/tracey-proto/src/lib.rs index da729ab7..8f68ff87 100644 --- a/crates/tracey-proto/src/lib.rs +++ b/crates/tracey-proto/src/lib.rs @@ -14,7 +14,7 @@ pub use tracey_api::*; /// Protocol version — bump this whenever any RPC method is added, removed, or changed. /// The daemon writes this into its PID file; connectors compare it before connecting /// to detect stale daemons running an incompatible build. -pub const PROTOCOL_VERSION: u32 = 4; +pub const PROTOCOL_VERSION: u32 = 5; // ============================================================================ // Request/Response types for the TraceyDaemon service @@ -86,6 +86,29 @@ pub struct UntestedResponse { pub by_section: Vec, } +/// Request for all-rules query +#[derive(Debug, Clone, Facet)] +#[facet(rename_all = "camelCase")] +pub struct AllRequest { + #[facet(default)] + pub spec: Option, + #[facet(default)] + pub impl_name: Option, + #[facet(default)] + pub prefix: Option, +} + +/// Response for all-rules query. +/// Returns every rule in the spec/impl with its body text populated. +#[derive(Debug, Clone, Facet)] +#[facet(rename_all = "camelCase")] +pub struct AllResponse { + pub spec: String, + pub impl_name: String, + pub total_rules: usize, + pub by_section: Vec, +} + /// Request for stale references query #[derive(Debug, Clone, Facet)] #[facet(rename_all = "camelCase")] @@ -653,6 +676,9 @@ pub trait TraceyDaemon { /// Get untested rules (rules with impl but no verify references) async fn untested(&self, req: UntestedRequest) -> UntestedResponse; + /// Get every rule in a spec/impl, with body text populated. + async fn all(&self, req: AllRequest) -> AllResponse; + /// Get stale references (code pointing to older rule versions) async fn stale(&self, req: StaleRequest) -> StaleResponse; diff --git a/crates/tracey/src/bridge/query.rs b/crates/tracey/src/bridge/query.rs index 9eab7f97..49e32e90 100644 --- a/crates/tracey/src/bridge/query.rs +++ b/crates/tracey/src/bridge/query.rs @@ -368,6 +368,50 @@ impl QueryClient { self.with_config_banner(output).await } + /// List every rule in a spec/impl with body text. + pub async fn all(&self, spec_impl: Option<&str>, prefix: Option<&str>) -> String { + let (spec, impl_name) = match self.checked_spec_impl(spec_impl).await { + Ok(values) => values, + Err(error) => return self.with_config_banner(format!("Error: {error}")).await, + }; + + let req = AllRequest { + spec, + impl_name, + prefix: prefix.map(String::from), + }; + + let output = match self.client.all(req).await { + Ok(response) => { + let mut output = format!( + "{}/{}: {} rules total\n\n", + response.spec, response.impl_name, response.total_rules + ); + + for section in &response.by_section { + if !section.rules.is_empty() { + output.push_str(&format!("## {}\n", section.section)); + for rule in §ion.rules { + output.push_str(&format!(" - {}\n", rule.id)); + } + output.push('\n'); + } + } + + output.push_str("---\n"); + output.push_str(&self.hint( + "tracey query all", + "tracey query to list all rules with body text", + )); + + output + } + Err(e) => format!("Error: {e:?}"), + }; + + self.with_config_banner(output).await + } + /// Get code units without rule references pub async fn unmapped(&self, spec_impl: Option<&str>, path: Option<&str>) -> String { let (spec, impl_name) = match self.checked_spec_impl(spec_impl).await { diff --git a/crates/tracey/src/daemon/client.rs b/crates/tracey/src/daemon/client.rs index b7c0fd92..3d783e7d 100644 --- a/crates/tracey/src/daemon/client.rs +++ b/crates/tracey/src/daemon/client.rs @@ -123,6 +123,12 @@ impl DaemonClient { self.with_client(|c| async move { c.untested(req).await }) .await } + pub async fn all( + &self, + req: tracey_proto::AllRequest, + ) -> Result { + self.with_client(|c| async move { c.all(req).await }).await + } pub async fn stale( &self, req: tracey_proto::StaleRequest, diff --git a/crates/tracey/src/daemon/service.rs b/crates/tracey/src/daemon/service.rs index 19365cca..2ea1b73b 100644 --- a/crates/tracey/src/daemon/service.rs +++ b/crates/tracey/src/daemon/service.rs @@ -322,6 +322,44 @@ impl TraceyDaemon for TraceyService { } } + /// Get every rule with body text populated + async fn all(&self, req: AllRequest) -> AllResponse { + let data = self.inner.engine.data().await; + let query = QueryEngine::new(&data); + + let (spec, impl_name) = + self.resolve_spec_impl(req.spec.as_deref(), req.impl_name.as_deref(), &data.config); + + if let Some(result) = query.all(&spec, &impl_name, req.prefix.as_deref()) { + AllResponse { + spec: result.spec, + impl_name: result.impl_name, + total_rules: result.total_rules, + by_section: result + .by_section + .into_iter() + .map(|(section, rules)| SectionRules { + section, + rules: rules + .into_iter() + .map(|r| tracey_proto::RuleRef { + id: r.id, + text: r.text, + }) + .collect(), + }) + .collect(), + } + } else { + AllResponse { + spec, + impl_name, + total_rules: 0, + by_section: vec![], + } + } + } + /// Get stale references async fn stale(&self, req: StaleRequest) -> StaleResponse { let data = self.inner.engine.data().await; diff --git a/crates/tracey/src/main.rs b/crates/tracey/src/main.rs index 43375990..064efe32 100644 --- a/crates/tracey/src/main.rs +++ b/crates/tracey/src/main.rs @@ -246,6 +246,17 @@ enum QueryCommand { prefix: Option, }, + /// List every rule in a spec/impl with body text (designed for --json export) + All { + /// Spec/impl to query (e.g., "my-spec/rust"). Optional if only one exists. + #[facet(args::named, default)] + spec_impl: Option, + + /// Filter by rule ID prefix + #[facet(args::named, default)] + prefix: Option, + }, + /// Show unmapped code units Unmapped { /// Spec/impl to query (e.g., "my-spec/rust"). Optional if only one exists. @@ -508,6 +519,12 @@ async fn main() -> Result<()> { .await, false, ), + QueryCommand::All { spec_impl, prefix } => ( + query_client + .all(spec_impl.as_deref(), prefix.as_deref()) + .await, + false, + ), QueryCommand::Unmapped { spec_impl, path } => ( query_client .unmapped(spec_impl.as_deref(), path.as_deref()) @@ -635,6 +652,35 @@ async fn query_json(qc: &bridge::query::QueryClient, query: QueryCommand) -> (St Err(e) => (json_error(&format!("{e:?}")), false), } } + QueryCommand::All { spec_impl, prefix } => { + let (spec, impl_name) = match spec_impl.as_deref() { + Some(raw) => { + let config = match qc.client.config().await { + Ok(config) => config, + Err(e) => { + return (json_error(&format!("failed to load config: {e:?}")), false); + } + }; + match validate_spec_impl_selection(Some(raw), &config) { + Ok(values) => values, + Err(error) => return (json_error(&error), false), + } + } + None => parse_spec_impl(None), + }; + let req = AllRequest { + spec, + impl_name, + prefix, + }; + match qc.client.all(req).await { + Ok(resp) => ( + facet_json::to_string_pretty(&resp).expect("JSON serialization failed"), + false, + ), + Err(e) => (json_error(&format!("{e:?}")), false), + } + } QueryCommand::Stale { spec_impl, prefix } => { let (spec, impl_name) = match spec_impl.as_deref() { Some(raw) => { diff --git a/crates/tracey/src/server.rs b/crates/tracey/src/server.rs index 830f5cad..4f139d7b 100644 --- a/crates/tracey/src/server.rs +++ b/crates/tracey/src/server.rs @@ -314,6 +314,42 @@ impl<'a> QueryEngine<'a> { }) } + /// Get every rule for a spec/impl with body text populated. + /// + /// Designed for machine-readable export (e.g. syncing requirements to an + /// external tracker). Unlike `uncovered`/`untested`, this populates each + /// `RuleRef.text` so callers do not need to issue a follow-up `rule()` call + /// per rule. + pub fn all( + &self, + spec: &str, + impl_name: &str, + prefix_filter: Option<&str>, + ) -> Option { + let key: ImplKey = (spec.to_string(), impl_name.to_string()); + let forward = self.data.forward_by_impl.get(&key)?; + + let rules: Vec<&ApiRule> = forward + .rules + .iter() + .filter(|r| { + prefix_filter + .map(|p| r.id.base.to_lowercase().starts_with(&p.to_lowercase())) + .unwrap_or(true) + }) + .collect(); + + let by_section = group_rules_by_section_with_text(&rules); + + Some(AllResult { + spec: spec.to_string(), + impl_name: impl_name.to_string(), + total_rules: rules.len(), + by_section, + prefix_filter: prefix_filter.map(|s| s.to_string()), + }) + } + /// Get stale references for a spec/impl, optionally filtered by rule ID prefix pub fn stale( &self, @@ -531,10 +567,23 @@ pub struct UntestedResult { pub prefix_filter: Option, } +#[derive(Debug, Clone)] +pub struct AllResult { + pub spec: String, + pub impl_name: String, + pub by_section: BTreeMap>, + pub total_rules: usize, + pub prefix_filter: Option, +} + #[derive(Debug, Clone)] pub struct RuleRef { pub id: RuleId, pub impl_refs: Vec, + /// Raw markdown body of the rule. Populated only when the caller asks for it + /// (e.g. `all()` for machine-readable export); `None` for the lightweight + /// summary commands (`uncovered`, `untested`). + pub text: Option, } #[derive(Debug, Clone)] @@ -645,6 +694,17 @@ impl RuleInfo { // ============================================================================ fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap> { + group_rules_by_section_inner(rules, false) +} + +fn group_rules_by_section_with_text(rules: &[&ApiRule]) -> BTreeMap> { + group_rules_by_section_inner(rules, true) +} + +fn group_rules_by_section_inner( + rules: &[&ApiRule], + include_text: bool, +) -> BTreeMap> { let mut result: BTreeMap> = BTreeMap::new(); for rule in rules { @@ -657,6 +717,11 @@ fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap> result.entry(section).or_default().push(RuleRef { id: rule.id.clone(), impl_refs: rule.impl_refs.clone(), + text: if include_text { + Some(rule.raw.clone()) + } else { + None + }, }); } diff --git a/crates/tracey/tests/integration_tests.rs b/crates/tracey/tests/integration_tests.rs index f6dbf2dc..e2352e45 100644 --- a/crates/tracey/tests/integration_tests.rs +++ b/crates/tracey/tests/integration_tests.rs @@ -182,6 +182,54 @@ async fn test_uncovered_with_prefix_filter() { } } +#[tokio::test] +async fn test_all_returns_every_rule_with_text() { + let service = create_test_service().await; + let req = AllRequest { + spec: Some("test".to_string()), + impl_name: Some("rust".to_string()), + prefix: None, + }; + + let response = rpc(service.client.all(req).await); + + assert_eq!(response.spec, "test"); + assert_eq!(response.impl_name, "rust"); + + // spec.md defines 8 rules across 3 sections. + let total: usize = response.by_section.iter().map(|s| s.rules.len()).sum(); + assert_eq!(total, 8, "expected 8 rules from fixture spec.md"); + assert_eq!(response.total_rules, 8); + + // Every rule must have a non-empty body — this is the whole point of `all`. + for section in &response.by_section { + for rule in §ion.rules { + let text = rule + .text + .as_ref() + .unwrap_or_else(|| panic!("rule {} missing text", rule.id)); + assert!(!text.is_empty(), "rule {} has empty text", rule.id); + } + } + + // Content fidelity: a known rule's body must match the spec markdown. + let login = response + .by_section + .iter() + .flat_map(|s| &s.rules) + .find(|r| r.id.base == "auth.login") + .expect("auth.login should be present"); + assert!( + login + .text + .as_ref() + .unwrap() + .contains("valid credentials"), + "auth.login body should contain 'valid credentials', got: {:?}", + login.text + ); +} + #[tokio::test] async fn test_untested_returns_rules() { let service = create_test_service().await;