fix: add --all to cargo fmt in script/test and autoformat codebase
cargo fmt without --all fails with "Failed to find targets" in workspace repos. This was blocking every story's gates. Also ran cargo fmt --all to fix all existing formatting issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,10 @@ mod tests {
|
||||
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
|
||||
// is_addressed is false; strip_bot_mention still strips "timmy ".
|
||||
let result = try_handle_command(&dispatch, "timmy ambient on");
|
||||
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"ambient on should fire even when is_addressed=false"
|
||||
);
|
||||
assert!(
|
||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be in ambient_rooms after ambient on"
|
||||
@@ -92,7 +95,10 @@ mod tests {
|
||||
};
|
||||
// Bare "ambient off" in an ambient room (is_addressed=false).
|
||||
let result = try_handle_command(&dispatch, "ambient off");
|
||||
assert!(result.is_some(), "bare ambient off should be handled without LLM");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"bare ambient off should be handled without LLM"
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode off"),
|
||||
@@ -161,7 +167,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ambient_invalid_args_returns_usage() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy ambient",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Handler for the `backlog` command — shows only Stage::Backlog items.
|
||||
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
use super::CommandContext;
|
||||
use super::status::{story_short_label, unmet_deps_from_items};
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
|
||||
pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option<String> {
|
||||
Some(build_backlog_output())
|
||||
@@ -94,16 +94,29 @@ mod tests {
|
||||
make_item("30_story_in_qa", "In QA", Stage::Qa),
|
||||
];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(output.contains("In Backlog"), "should show backlog item: {output}");
|
||||
assert!(!output.contains("In Progress"), "should not show coding items: {output}");
|
||||
assert!(!output.contains("In QA"), "should not show QA items: {output}");
|
||||
assert!(
|
||||
output.contains("In Backlog"),
|
||||
"should show backlog item: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("In Progress"),
|
||||
"should not show coding items: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("In QA"),
|
||||
"should not show QA items: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC: shows number, type, name -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn backlog_shows_number_type_and_name() {
|
||||
let items = vec![make_item("42_story_my_feature", "My Feature", Stage::Backlog)];
|
||||
let items = vec![make_item(
|
||||
"42_story_my_feature",
|
||||
"My Feature",
|
||||
Stage::Backlog,
|
||||
)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(
|
||||
output.contains("42 [story] — My Feature"),
|
||||
@@ -116,7 +129,12 @@ mod tests {
|
||||
#[test]
|
||||
fn backlog_shows_waiting_on_for_unmet_deps() {
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Backlog, vec![999]),
|
||||
make_item_with_deps(
|
||||
"10_story_waiting",
|
||||
"Waiting Story",
|
||||
Stage::Backlog,
|
||||
vec![999],
|
||||
),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||
];
|
||||
let output = build_backlog_from_items(&items);
|
||||
@@ -150,16 +168,17 @@ mod tests {
|
||||
fn backlog_no_waiting_on_when_no_deps() {
|
||||
let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(!output.contains("waiting on"), "no dep suffix when no deps: {output}");
|
||||
assert!(
|
||||
!output.contains("waiting on"),
|
||||
"no dep suffix when no deps: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC: command is registered in the registry ----------------------------
|
||||
|
||||
#[test]
|
||||
fn backlog_command_in_registry() {
|
||||
let found = super::super::commands()
|
||||
.iter()
|
||||
.any(|c| c.name == "backlog");
|
||||
let found = super::super::commands().iter().any(|c| c.name == "backlog");
|
||||
assert!(found, "backlog must be registered in commands()");
|
||||
}
|
||||
|
||||
@@ -171,7 +190,10 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap_or_default();
|
||||
assert!(output.contains("backlog"), "backlog should appear in help output: {output}");
|
||||
assert!(
|
||||
output.contains("backlog"),
|
||||
"backlog should appear in help output: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,7 +203,10 @@ mod tests {
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy backlog",
|
||||
);
|
||||
assert!(result.is_some(), "backlog command should match and return Some");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"backlog command should match and return Some"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -192,7 +217,10 @@ mod tests {
|
||||
"@timmy backlog",
|
||||
);
|
||||
let output = result.unwrap_or_default();
|
||||
assert!(output.contains("Backlog"), "backlog output should contain Backlog header: {output}");
|
||||
assert!(
|
||||
output.contains("Backlog"),
|
||||
"backlog output should contain Backlog header: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- empty backlog --------------------------------------------------------
|
||||
@@ -201,6 +229,9 @@ mod tests {
|
||||
fn backlog_shows_none_when_empty() {
|
||||
let items = vec![make_item("1_story_done", "Done", Stage::Coding)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(output.contains("*(none)*"), "should show none when no backlog items: {output}");
|
||||
assert!(
|
||||
output.contains("*(none)*"),
|
||||
"should show none when no backlog items: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::status::story_short_label;
|
||||
use super::CommandContext;
|
||||
use super::status::story_short_label;
|
||||
|
||||
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
||||
/// all-time total.
|
||||
@@ -102,7 +102,10 @@ mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
|
||||
fn write_token_records(
|
||||
root: &std::path::Path,
|
||||
records: &[crate::agents::token_usage::TokenUsageRecord],
|
||||
) {
|
||||
for r in records {
|
||||
crate::agents::token_usage::append_record(root, r).unwrap();
|
||||
}
|
||||
@@ -118,7 +121,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
fn make_record(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
cost: f64,
|
||||
hours_ago: i64,
|
||||
) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
||||
crate::agents::token_usage::TokenUsageRecord {
|
||||
story_id: story_id.to_string(),
|
||||
@@ -157,55 +165,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("cost"), "help should list cost command: {output}");
|
||||
assert!(
|
||||
output.contains("cost"),
|
||||
"help should list cost command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_no_records() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("No usage records found"), "should show empty message: {output}");
|
||||
assert!(
|
||||
output.contains("No usage records found"),
|
||||
"should show empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_24h_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
|
||||
assert!(
|
||||
output.contains("**Last 24h:** $2.00"),
|
||||
"should show 24h total: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_excludes_old_from_24h() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
|
||||
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
|
||||
assert!(
|
||||
output.contains("**Last 24h:** $1.00"),
|
||||
"should only count recent: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("**All-time:** $6.00"),
|
||||
"all-time should include everything: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_top_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
|
||||
assert!(
|
||||
output.contains("Top Stories"),
|
||||
"should have top stories section: {output}"
|
||||
);
|
||||
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
||||
let pos_42 = output.find("42").unwrap();
|
||||
let pos_43 = output.find("43").unwrap();
|
||||
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
|
||||
assert!(
|
||||
pos_42 < pos_43,
|
||||
"story 42 should appear before 43 (sorted by cost): {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,45 +255,75 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut records = Vec::new();
|
||||
for i in 1..=7 {
|
||||
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
|
||||
records.push(make_record(
|
||||
&format!("{i}_story_s{i}"),
|
||||
"coder-1",
|
||||
i as f64,
|
||||
1,
|
||||
));
|
||||
}
|
||||
write_token_records(tmp.path(), &records);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
||||
let top_section = output.split("**By Agent Type").next().unwrap();
|
||||
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
|
||||
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
|
||||
assert!(
|
||||
!top_section.contains("• 1 —"),
|
||||
"story 1 should not be in top 5: {output}"
|
||||
);
|
||||
assert!(
|
||||
!top_section.contains("• 2 —"),
|
||||
"story 2 should not be in top 5: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_agent_type_breakdown() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
|
||||
assert!(
|
||||
output.contains("By Agent Type"),
|
||||
"should have agent type section: {output}"
|
||||
);
|
||||
assert!(output.contains("coder"), "should show coder type: {output}");
|
||||
assert!(output.contains("qa"), "should show qa type: {output}");
|
||||
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
|
||||
assert!(
|
||||
output.contains("mergemaster"),
|
||||
"should show mergemaster type: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_all_time_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
|
||||
assert!(
|
||||
output.contains("**All-time:** $10.00"),
|
||||
"should show all-time total: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy COST",
|
||||
);
|
||||
assert!(result.is_some(), "COST should match case-insensitively");
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,16 @@ fn read_cached_coverage(project_root: &std::path::Path) -> String {
|
||||
fn read_coverage_report(path: &std::path::Path) -> String {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}"),
|
||||
Err(e) => {
|
||||
return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
let report: CoverageReport = match serde_json::from_str(&content) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return format!("**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}"),
|
||||
Err(e) => {
|
||||
return format!("**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
format_coverage_report(&report)
|
||||
@@ -81,13 +85,22 @@ fn format_coverage_report(report: &CoverageReport) -> String {
|
||||
// Top 5 lowest-covered files (already sorted ascending in the JSON, but sort
|
||||
// defensively here so the display is correct even if the file was hand-edited).
|
||||
let mut sorted: Vec<&FileCoverage> = report.files.iter().collect();
|
||||
sorted.sort_by(|a, b| a.coverage.partial_cmp(&b.coverage).unwrap_or(std::cmp::Ordering::Equal));
|
||||
sorted.sort_by(|a, b| {
|
||||
a.coverage
|
||||
.partial_cmp(&b.coverage)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let targets: Vec<&FileCoverage> = sorted.into_iter().take(5).collect();
|
||||
if !targets.is_empty() {
|
||||
out.push_str("\n**Top 5 files needing coverage:**\n");
|
||||
for (i, file) in targets.iter().enumerate() {
|
||||
out.push_str(&format!("{}. {} — {:.1}%\n", i + 1, file.path, file.coverage));
|
||||
out.push_str(&format!(
|
||||
"{}. {} — {:.1}%\n",
|
||||
i + 1,
|
||||
file.path,
|
||||
file.coverage
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +175,13 @@ fn run_coverage(project_root: &std::path::Path) -> String {
|
||||
// Replace the "cached" label with "fresh".
|
||||
result = result.replacen("Coverage (cached)", "Coverage (fresh)", 1);
|
||||
// Replace the cached hint with a pass/fail indicator.
|
||||
let pass_indicator = if out.status.success() { "PASS" } else { "FAIL: coverage below threshold" };
|
||||
result = result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
||||
let pass_indicator = if out.status.success() {
|
||||
"PASS"
|
||||
} else {
|
||||
"FAIL: coverage below threshold"
|
||||
};
|
||||
result =
|
||||
result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -322,9 +340,18 @@ mod tests {
|
||||
let output = handle_coverage(&ctx).unwrap();
|
||||
|
||||
assert!(output.contains("72.5"), "should include overall: {output}");
|
||||
assert!(output.contains("60.0"), "should include threshold: {output}");
|
||||
assert!(output.contains("15.0"), "should include lowest-covered file pct: {output}");
|
||||
assert!(output.contains("server/src/low.rs"), "should include lowest-covered file path: {output}");
|
||||
assert!(
|
||||
output.contains("60.0"),
|
||||
"should include threshold: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("15.0"),
|
||||
"should include lowest-covered file pct: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("server/src/low.rs"),
|
||||
"should include lowest-covered file path: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,9 +375,18 @@ mod tests {
|
||||
let output = handle_coverage(&ctx).unwrap();
|
||||
|
||||
assert!(output.contains("a.rs"), "should show lowest file: {output}");
|
||||
assert!(output.contains("e.rs"), "should show 5th lowest file: {output}");
|
||||
assert!(!output.contains("f.rs"), "should not show 6th file: {output}");
|
||||
assert!(!output.contains("g.rs"), "should not show 7th file: {output}");
|
||||
assert!(
|
||||
output.contains("e.rs"),
|
||||
"should show 5th lowest file: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("f.rs"),
|
||||
"should not show 6th file: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("g.rs"),
|
||||
"should not show 7th file: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -466,15 +502,24 @@ mod tests {
|
||||
overall: 66.25,
|
||||
threshold: 60.0,
|
||||
files: vec![
|
||||
FileCoverage { path: "a.rs".to_string(), coverage: 10.0 },
|
||||
FileCoverage { path: "b.rs".to_string(), coverage: 80.0 },
|
||||
FileCoverage {
|
||||
path: "a.rs".to_string(),
|
||||
coverage: 10.0,
|
||||
},
|
||||
FileCoverage {
|
||||
path: "b.rs".to_string(),
|
||||
coverage: 80.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
let result = format_coverage_report(&report);
|
||||
assert!(result.contains("66.2"), "should show overall: {result}");
|
||||
assert!(result.contains("60.0"), "should show threshold: {result}");
|
||||
assert!(result.contains("a.rs"), "should show lowest file: {result}");
|
||||
assert!(result.contains("10.0"), "should show lowest file pct: {result}");
|
||||
assert!(
|
||||
result.contains("10.0"),
|
||||
"should show lowest file pct: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -490,9 +535,18 @@ Frontend line coverage: 70.0%\n\
|
||||
PASS: Coverage 66.25% meets threshold 60.00%\n\
|
||||
";
|
||||
let result = parse_coverage_output(sample, true);
|
||||
assert!(result.contains("62.5"), "should include Rust coverage: {result}");
|
||||
assert!(result.contains("70.0"), "should include Frontend coverage: {result}");
|
||||
assert!(result.contains("66.25"), "should include Overall coverage: {result}");
|
||||
assert!(
|
||||
result.contains("62.5"),
|
||||
"should include Rust coverage: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("70.0"),
|
||||
"should include Frontend coverage: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("66.25"),
|
||||
"should include Overall coverage: {result}"
|
||||
);
|
||||
assert!(result.contains("PASS"), "should indicate PASS: {result}");
|
||||
}
|
||||
|
||||
|
||||
@@ -128,14 +128,20 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("depends"), "help should list depends command: {output}");
|
||||
assert!(
|
||||
output.contains("depends"),
|
||||
"help should list depends command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depends_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = depends_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(output.contains("Usage"), "no args should show usage: {output}");
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,10 +194,9 @@ mod tests {
|
||||
output.contains("477") && output.contains("478"),
|
||||
"response should mention dep numbers: {output}"
|
||||
);
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let contents =
|
||||
std::fs::read_to_string(tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("depends_on: [477, 478]"),
|
||||
"file should have depends_on set: {contents}"
|
||||
@@ -212,10 +217,9 @@ mod tests {
|
||||
output.contains("Cleared"),
|
||||
"should confirm clearing deps: {output}"
|
||||
);
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/10_story_bar.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let contents =
|
||||
std::fs::read_to_string(tmp.path().join(".huskies/work/2_current/10_story_bar.md"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!contents.contains("depends_on"),
|
||||
"file should have depends_on cleared: {contents}"
|
||||
|
||||
@@ -100,9 +100,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn git_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("git"), "help should list git command: {output}");
|
||||
assert!(
|
||||
output.contains("git"),
|
||||
"help should list git command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +204,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn git_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy GIT",
|
||||
);
|
||||
assert!(result.is_some(), "GIT should match case-insensitively");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Handler for the `help` command.
|
||||
|
||||
use super::{commands, CommandContext};
|
||||
use super::{CommandContext, commands};
|
||||
|
||||
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
||||
@@ -14,7 +14,7 @@ pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{try_cmd_addressed, commands};
|
||||
use super::super::tests::{commands, try_cmd_addressed};
|
||||
|
||||
#[test]
|
||||
fn help_command_matches() {
|
||||
@@ -74,7 +74,10 @@ mod tests {
|
||||
fn help_output_includes_status() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("status"), "help should list status command: {output}");
|
||||
assert!(
|
||||
output.contains("status"),
|
||||
"help should list status command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -86,7 +89,9 @@ mod tests {
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let marker = format!("**{}**", c.name);
|
||||
let pos = output.find(&marker).expect("command must appear in help as **name**");
|
||||
let pos = output
|
||||
.find(&marker)
|
||||
.expect("command must appear in help as **name**");
|
||||
(pos, c.name)
|
||||
})
|
||||
.collect();
|
||||
@@ -94,20 +99,29 @@ mod tests {
|
||||
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
|
||||
let mut sorted = names_in_order.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
|
||||
assert_eq!(
|
||||
names_in_order, sorted,
|
||||
"commands must appear in alphabetical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_ambient() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("ambient"), "help should list ambient command: {output}");
|
||||
assert!(
|
||||
output.contains("ambient"),
|
||||
"help should list ambient command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_htop() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("htop"), "help should list htop command: {output}");
|
||||
assert!(
|
||||
output.contains("htop"),
|
||||
"help should list htop command: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,53 @@ fn loc_top_n(project_root: &std::path::Path, top_n: usize) -> String {
|
||||
fn is_source_extension(ext: &str) -> bool {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h"
|
||||
| "hpp" | "cs" | "rb" | "swift" | "kt" | "scala" | "hs" | "ml" | "ex" | "exs"
|
||||
| "clj" | "lua" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "toml" | "yaml"
|
||||
| "yml" | "json" | "md" | "html" | "css" | "scss" | "less" | "sql" | "graphql"
|
||||
| "proto" | "tf" | "hcl" | "nix" | "r" | "jl" | "dart" | "vue" | "svelte"
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "rb"
|
||||
| "swift"
|
||||
| "kt"
|
||||
| "scala"
|
||||
| "hs"
|
||||
| "ml"
|
||||
| "ex"
|
||||
| "exs"
|
||||
| "clj"
|
||||
| "lua"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "ps1"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "json"
|
||||
| "md"
|
||||
| "html"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "less"
|
||||
| "sql"
|
||||
| "graphql"
|
||||
| "proto"
|
||||
| "tf"
|
||||
| "hcl"
|
||||
| "nix"
|
||||
| "r"
|
||||
| "jl"
|
||||
| "dart"
|
||||
| "vue"
|
||||
| "svelte"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,7 +244,10 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("loc"), "help should list loc command: {output}");
|
||||
assert!(
|
||||
output.contains("loc"),
|
||||
"help should list loc command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,7 +265,10 @@ mod tests {
|
||||
);
|
||||
// At most 10 entries (numbered lines "1." through "10.")
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 10, "default should return at most 10 files, got {count}");
|
||||
assert!(
|
||||
count <= 10,
|
||||
"default should return at most 10 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,7 +281,10 @@ mod tests {
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "5");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 5, "loc 5 should return at most 5 files, got {count}");
|
||||
assert!(
|
||||
count <= 5,
|
||||
"loc 5 should return at most 5 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -246,7 +297,10 @@ mod tests {
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "20");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 20, "loc 20 should return at most 20 files, got {count}");
|
||||
assert!(
|
||||
count <= 20,
|
||||
"loc 20 should return at most 20 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -110,7 +110,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
if file_num == num_str
|
||||
&& let Some(c) = crate::db::read_content(&id)
|
||||
{
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
@@ -119,7 +121,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
let stages = [
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in &stages {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
|
||||
@@ -86,9 +86,7 @@ pub(super) fn handle_test(ctx: &CommandContext) -> Option<String> {
|
||||
let mut result = format!("**Test: {status}**\n\n");
|
||||
|
||||
if tests_passed > 0 || tests_failed > 0 {
|
||||
result.push_str(&format!(
|
||||
"{tests_passed} passed, {tests_failed} failed\n\n"
|
||||
));
|
||||
result.push_str(&format!("{tests_passed} passed, {tests_failed} failed\n\n"));
|
||||
}
|
||||
|
||||
result.push_str(&format!("```\n{truncated}\n```"));
|
||||
@@ -128,7 +126,11 @@ fn parse_test_counts(output: &str) -> (u64, u64) {
|
||||
fn extract_count(line: &str, label: &str) -> Option<u64> {
|
||||
let pos = line.find(label)?;
|
||||
let before = line[..pos].trim_end();
|
||||
let num_str: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let num_str: String = before
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if num_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -250,10 +252,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_command_works_via_dispatch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_script(
|
||||
dir.path(),
|
||||
"#!/usr/bin/env bash\necho 'ok'\nexit 0\n",
|
||||
);
|
||||
write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n");
|
||||
let agents = test_agents();
|
||||
let ambient = test_ambient();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
@@ -317,8 +316,14 @@ mod tests {
|
||||
let ambient = test_ambient();
|
||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
|
||||
let output = handle_test(&ctx).unwrap();
|
||||
assert!(output.contains("PASS"), "no-arg should use project root: {output}");
|
||||
assert!(output.contains('7'), "should show count from project root script: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"no-arg should use project root: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains('7'),
|
||||
"should show count from project root script: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -329,8 +334,14 @@ mod tests {
|
||||
let ambient = test_ambient();
|
||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "541");
|
||||
let output = handle_test(&ctx).unwrap();
|
||||
assert!(output.contains("PASS"), "should run tests in worktree: {output}");
|
||||
assert!(output.contains('2'), "should show count from worktree script: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"should run tests in worktree: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains('2'),
|
||||
"should show count from worktree script: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -382,6 +393,9 @@ mod tests {
|
||||
"run_tests with story number must respond via dispatch"
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("PASS"), "should PASS for valid worktree: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"should PASS for valid worktree: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::CommandContext;
|
||||
use crate::http::mcp::wizard_tools::{
|
||||
generation_hint, is_script_step, step_output_path, write_if_missing,
|
||||
};
|
||||
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
||||
use crate::io::wizard::{StepStatus, WizardState, format_wizard_state};
|
||||
|
||||
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||
let sub = ctx.args.trim().to_ascii_lowercase();
|
||||
@@ -84,17 +84,16 @@ fn wizard_confirm_reply(ctx: &CommandContext) -> String {
|
||||
let content = state.steps[idx].content.clone();
|
||||
|
||||
// Write content to disk (only if a file path exists and the file is absent).
|
||||
let write_msg =
|
||||
if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||
let executable = is_script_step(step);
|
||||
match write_if_missing(path, c, executable) {
|
||||
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||
let executable = is_script_step(step);
|
||||
match write_if_missing(path, c, executable) {
|
||||
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if let Err(e) = state.confirm_step(step) {
|
||||
return format!("Cannot confirm step: {e}");
|
||||
@@ -140,10 +139,7 @@ fn wizard_skip_reply(ctx: &CommandContext) -> String {
|
||||
}
|
||||
|
||||
if state.completed {
|
||||
format!(
|
||||
"Step '{}' skipped. Setup wizard complete!",
|
||||
step.label()
|
||||
)
|
||||
format!("Step '{}' skipped. Setup wizard complete!", step.label())
|
||||
} else {
|
||||
let next = &state.steps[state.current_step_index()];
|
||||
format!(
|
||||
|
||||
@@ -78,9 +78,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn show_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("show"), "help should list show command: {output}");
|
||||
assert!(
|
||||
output.contains("show"),
|
||||
"help should list show command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,7 +174,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn show_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy SHOW 1",
|
||||
);
|
||||
assert!(result.is_some(), "SHOW should match case-insensitively");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +119,13 @@ fn build_status_from_items(
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> =
|
||||
crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
@@ -165,10 +164,8 @@ fn build_status_from_items(
|
||||
}
|
||||
|
||||
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
||||
let mut blocked_items: Vec<&PipelineItem> = items
|
||||
.iter()
|
||||
.filter(|i| i.stage.is_blocked())
|
||||
.collect();
|
||||
let mut blocked_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| i.stage.is_blocked()).collect();
|
||||
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
if !blocked_items.is_empty() {
|
||||
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
||||
@@ -294,13 +291,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_command_matches() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy status",
|
||||
);
|
||||
assert!(result.is_some(), "status command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_returns_pipeline_text() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy status",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
@@ -310,7 +315,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy STATUS",
|
||||
);
|
||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||
}
|
||||
|
||||
@@ -318,7 +327,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_extracts_number_and_name() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert_eq!(label, "293 [story] — Register all bot commands");
|
||||
}
|
||||
|
||||
@@ -336,7 +348,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_does_not_include_underscore_slug() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands_in_the_command_registry",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert!(
|
||||
!label.contains("story_register"),
|
||||
"label should not contain the slug portion: {label}"
|
||||
@@ -345,19 +360,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_bug_type() {
|
||||
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
|
||||
let label = story_short_label(
|
||||
"375_bug_default_project_toml",
|
||||
Some("Default project.toml issue"),
|
||||
);
|
||||
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_spike_type() {
|
||||
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
|
||||
let label = story_short_label(
|
||||
"61_spike_filesystem_watcher_architecture",
|
||||
Some("Filesystem watcher architecture"),
|
||||
);
|
||||
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_refactor_type() {
|
||||
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
|
||||
let label = story_short_label(
|
||||
"260_refactor_upgrade_libsqlite3_sys",
|
||||
Some("Upgrade libsqlite3-sys"),
|
||||
);
|
||||
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
||||
}
|
||||
|
||||
@@ -506,7 +530,12 @@ mod tests {
|
||||
// Story 10 depends on story 999, which is NOT in all_items (treated as met)
|
||||
// OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Coding, vec![999]),
|
||||
make_item_with_deps(
|
||||
"10_story_waiting",
|
||||
"Waiting Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||
];
|
||||
|
||||
@@ -526,11 +555,20 @@ mod tests {
|
||||
|
||||
// Dep 999 is in Done stage — met.
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_unblocked", "Unblocked Story", Stage::Coding, vec![999]),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
}),
|
||||
make_item_with_deps(
|
||||
"10_story_unblocked",
|
||||
"Unblocked Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item(
|
||||
"999_story_dep",
|
||||
"Dep Story",
|
||||
Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
@@ -678,8 +716,12 @@ mod tests {
|
||||
|
||||
// Must appear under Done, not Backlog.
|
||||
let done_pos = output.find("**Done**").expect("Done section must exist");
|
||||
let backlog_pos = output.find("**Backlog**").expect("Backlog section must exist");
|
||||
let story_pos = output.find("503 [story]").expect("story must appear in output");
|
||||
let backlog_pos = output
|
||||
.find("**Backlog**")
|
||||
.expect("Backlog section must exist");
|
||||
let story_pos = output
|
||||
.find("503 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > done_pos,
|
||||
|
||||
@@ -33,17 +33,13 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
match find_story_by_number(num_str) {
|
||||
Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
|
||||
None => Some(format!(
|
||||
"Story **{num_str}** not found in the pipeline."
|
||||
)),
|
||||
None => Some(format!("Story **{num_str}** not found in the pipeline.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
|
||||
/// CRDT state. Returns `(story_id, PipelineItem)` for the first match.
|
||||
fn find_story_by_number(
|
||||
num_str: &str,
|
||||
) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||
fn find_story_by_number(num_str: &str) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||
let items = crate::pipeline_state::read_all_typed();
|
||||
for item in items {
|
||||
let file_num = item
|
||||
@@ -74,7 +70,10 @@ fn build_triage_dump(
|
||||
};
|
||||
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
||||
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
|
||||
let name = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.name.as_deref())
|
||||
.unwrap_or("(unnamed)");
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -147,10 +146,7 @@ fn build_triage_dump(
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
||||
|
||||
// ---- git diff --stat ----
|
||||
let diff_stat = run_git(
|
||||
&wt_path,
|
||||
&["diff", "--stat", "master...HEAD"],
|
||||
);
|
||||
let diff_stat = run_git(&wt_path, &["diff", "--stat", "master...HEAD"]);
|
||||
if !diff_stat.is_empty() {
|
||||
out.push_str("**Diff stat (vs master):**\n```\n");
|
||||
out.push_str(&diff_stat);
|
||||
@@ -162,12 +158,7 @@ fn build_triage_dump(
|
||||
// ---- Last 5 commits on feature branch ----
|
||||
let log = run_git(
|
||||
&wt_path,
|
||||
&[
|
||||
"log",
|
||||
"master..HEAD",
|
||||
"--pretty=format:%h %s",
|
||||
"-5",
|
||||
],
|
||||
&["log", "master..HEAD", "--pretty=format:%h %s", "-5"],
|
||||
);
|
||||
if !log.is_empty() {
|
||||
out.push_str("**Recent commits (branch only):**\n```\n");
|
||||
@@ -192,10 +183,15 @@ fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
|
||||
if let Some(text) = trimmed
|
||||
.strip_prefix("- [x] ")
|
||||
.or_else(|| trimmed.strip_prefix("- [X] "))
|
||||
{
|
||||
Some((true, text.to_string()))
|
||||
} else {
|
||||
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
|
||||
trimmed
|
||||
.strip_prefix("- [ ] ")
|
||||
.map(|text| (false, text.to_string()))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -248,7 +244,10 @@ mod tests {
|
||||
#[test]
|
||||
fn whatsup_command_is_not_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
||||
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
|
||||
assert!(
|
||||
!found,
|
||||
"whatsup command must not be in the registry (renamed to status)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,7 +339,10 @@ mod tests {
|
||||
"---\nname: Backlog Item\n---\n",
|
||||
);
|
||||
let output = status_triage_cmd(tmp.path(), "9901").unwrap();
|
||||
assert!(output.contains("9901"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("9901"),
|
||||
"should show story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Backlog Item"),
|
||||
"should show story name: {output}"
|
||||
@@ -361,7 +363,10 @@ mod tests {
|
||||
"---\nname: QA Item\n---\n",
|
||||
);
|
||||
let output = status_triage_cmd(tmp.path(), "9902").unwrap();
|
||||
assert!(output.contains("9902"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("9902"),
|
||||
"should show story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("QA Item"),
|
||||
"should show story name: {output}"
|
||||
@@ -439,7 +444,10 @@ mod tests {
|
||||
output.contains("depends_on") || output.contains("#477"),
|
||||
"should show depends_on field: {output}"
|
||||
);
|
||||
assert!(output.contains("478"), "should list all dependency numbers: {output}");
|
||||
assert!(
|
||||
output.contains("478"),
|
||||
"should list all dependency numbers: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -459,7 +467,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// -- parse_acceptance_criteria -----------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -479,5 +486,4 @@ mod tests {
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
//! and returns a confirmation.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
|
||||
use crate::io::story_metadata::{
|
||||
clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter,
|
||||
set_front_matter_field,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Handle the `unblock` command.
|
||||
@@ -37,9 +40,7 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,9 +72,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
||||
let has_merge_failure = meta.merge_failure.is_some();
|
||||
|
||||
if !has_blocked && !has_merge_failure {
|
||||
return format!(
|
||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||
);
|
||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||
}
|
||||
|
||||
let mut updated = contents;
|
||||
@@ -94,9 +93,16 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
let mut cleared = Vec::new();
|
||||
if has_blocked { cleared.push("blocked"); }
|
||||
if has_merge_failure { cleared.push("merge_failure"); }
|
||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||
if has_blocked {
|
||||
cleared.push("blocked");
|
||||
}
|
||||
if has_merge_failure {
|
||||
cleared.push("merge_failure");
|
||||
}
|
||||
format!(
|
||||
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||
cleared.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Core unblock logic: reset blocked state for a known story file path.
|
||||
@@ -121,9 +127,7 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
||||
let has_merge_failure = meta.merge_failure.is_some();
|
||||
|
||||
if !has_blocked && !has_merge_failure {
|
||||
return format!(
|
||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||
);
|
||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||
}
|
||||
|
||||
// Clear the blocked flag if present.
|
||||
@@ -147,9 +151,16 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
||||
}
|
||||
|
||||
let mut cleared = Vec::new();
|
||||
if has_blocked { cleared.push("blocked"); }
|
||||
if has_merge_failure { cleared.push("merge_failure"); }
|
||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||
if has_blocked {
|
||||
cleared.push("blocked");
|
||||
}
|
||||
if has_merge_failure {
|
||||
cleared.push("merge_failure");
|
||||
}
|
||||
format!(
|
||||
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||
cleared.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,7 +287,8 @@ mod tests {
|
||||
let contents = crate::db::read_content("9903_story_stuck")
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||
tmp.path()
|
||||
.join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
|
||||
@@ -17,9 +17,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
if commits.is_empty() {
|
||||
let msg = match &tag {
|
||||
Some(t) => format!(
|
||||
"No unreleased stories since the last release tag **{t}**."
|
||||
),
|
||||
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||
};
|
||||
return Some(msg);
|
||||
@@ -36,9 +34,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
if stories.is_empty() {
|
||||
let msg = match &tag {
|
||||
Some(t) => format!(
|
||||
"No unreleased stories since the last release tag **{t}**."
|
||||
),
|
||||
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||
};
|
||||
return Some(msg);
|
||||
@@ -50,8 +46,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
|
||||
};
|
||||
for (num, slug) in &stories {
|
||||
let name = find_story_name(root, &num.to_string())
|
||||
.unwrap_or_else(|| slug_to_name(slug));
|
||||
let name = find_story_name(root, &num.to_string()).unwrap_or_else(|| slug_to_name(slug));
|
||||
out.push_str(&format!("- **{num}** — {name}\n"));
|
||||
}
|
||||
Some(out)
|
||||
@@ -79,10 +74,7 @@ fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
|
||||
|
||||
/// Return the subjects of all `huskies: merge …` commits reachable from HEAD
|
||||
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
|
||||
fn list_merge_commits_since(
|
||||
root: &std::path::Path,
|
||||
since_tag: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
fn list_merge_commits_since(root: &std::path::Path, since_tag: Option<&str>) -> Vec<String> {
|
||||
use std::process::Command;
|
||||
|
||||
let range = match since_tag {
|
||||
@@ -153,7 +145,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
if file_num == num_str
|
||||
&& let Some(c) = crate::db::read_content(&id)
|
||||
{
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
@@ -162,7 +156,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in STAGES {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
@@ -225,7 +224,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreleased_command_is_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "unreleased");
|
||||
let found = super::super::commands()
|
||||
.iter()
|
||||
.any(|c| c.name == "unreleased");
|
||||
assert!(found, "unreleased command must be in the registry");
|
||||
}
|
||||
|
||||
@@ -249,7 +250,10 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
|
||||
// Should return some message (not panic), either about no tags or no commits.
|
||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
||||
assert!(
|
||||
!output.is_empty(),
|
||||
"should return a non-empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,7 +265,10 @@ mod tests {
|
||||
let output = unreleased_cmd_with_root(repo_root).unwrap();
|
||||
// The response should mention "unreleased" or "no unreleased" — just make
|
||||
// sure it's non-empty and doesn't panic.
|
||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
||||
assert!(
|
||||
!output.is_empty(),
|
||||
"should return a non-empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,7 +278,10 @@ mod tests {
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy UNRELEASED",
|
||||
);
|
||||
assert!(result.is_some(), "UNRELEASED should match case-insensitively");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"UNRELEASED should match case-insensitively"
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_story_from_subject ------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user