Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion crates/tracey-proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +86,29 @@ pub struct UntestedResponse {
pub by_section: Vec<SectionRules>,
}

/// Request for all-rules query
#[derive(Debug, Clone, Facet)]
#[facet(rename_all = "camelCase")]
pub struct AllRequest {
#[facet(default)]
pub spec: Option<String>,
#[facet(default)]
pub impl_name: Option<String>,
#[facet(default)]
pub prefix: Option<String>,
}

/// 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<SectionRules>,
}

/// Request for stale references query
#[derive(Debug, Clone, Facet)]
#[facet(rename_all = "camelCase")]
Expand Down Expand Up @@ -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;

Expand Down
44 changes: 44 additions & 0 deletions crates/tracey/src/bridge/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &section.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 {
Expand Down
6 changes: 6 additions & 0 deletions crates/tracey/src/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<tracey_proto::AllResponse, roam::RoamError> {
self.with_client(|c| async move { c.all(req).await }).await
}
pub async fn stale(
&self,
req: tracey_proto::StaleRequest,
Expand Down
38 changes: 38 additions & 0 deletions crates/tracey/src/daemon/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions crates/tracey/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,17 @@ enum QueryCommand {
prefix: Option<String>,
},

/// 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<String>,

/// Filter by rule ID prefix
#[facet(args::named, default)]
prefix: Option<String>,
},

/// Show unmapped code units
Unmapped {
/// Spec/impl to query (e.g., "my-spec/rust"). Optional if only one exists.
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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) => {
Expand Down
65 changes: 65 additions & 0 deletions crates/tracey/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AllResult> {
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,
Expand Down Expand Up @@ -531,10 +567,23 @@ pub struct UntestedResult {
pub prefix_filter: Option<String>,
}

#[derive(Debug, Clone)]
pub struct AllResult {
pub spec: String,
pub impl_name: String,
pub by_section: BTreeMap<String, Vec<RuleRef>>,
pub total_rules: usize,
pub prefix_filter: Option<String>,
}

#[derive(Debug, Clone)]
pub struct RuleRef {
pub id: RuleId,
pub impl_refs: Vec<ApiCodeRef>,
/// 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<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -645,6 +694,17 @@ impl RuleInfo {
// ============================================================================

fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>> {
group_rules_by_section_inner(rules, false)
}

fn group_rules_by_section_with_text(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>> {
group_rules_by_section_inner(rules, true)
}

fn group_rules_by_section_inner(
rules: &[&ApiRule],
include_text: bool,
) -> BTreeMap<String, Vec<RuleRef>> {
let mut result: BTreeMap<String, Vec<RuleRef>> = BTreeMap::new();

for rule in rules {
Expand All @@ -657,6 +717,11 @@ fn group_rules_by_section(rules: &[&ApiRule]) -> BTreeMap<String, Vec<RuleRef>>
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
},
});
}

Expand Down
48 changes: 48 additions & 0 deletions crates/tracey/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &section.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;
Expand Down
Loading