use super::*;
use crate::rules::load_builtin_rules;
fn run(input: ToolExecutionInput) -> CompactResult {
let rules = load_builtin_rules();
reduce_execution_with_rules(input, &rules, &ReduceOptions::default())
}
fn make_input(tool_name: &str, argv: &[&str], stdout: &str) -> ToolExecutionInput {
ToolExecutionInput {
tool_name: tool_name.to_owned(),
argv: Some(argv.iter().map(|s| s.to_string()).collect()),
stdout: Some(stdout.to_owned()),
..Default::default()
}
}
#[test]
fn tokenize_basic() {
assert_eq!(
tokenize_command("git status --short"),
vec!["git", "status", "--short"]
);
}
#[test]
fn tokenize_quoted() {
assert_eq!(
tokenize_command(r#"echo "hello world""#),
vec!["echo", "hello world"]
);
}
#[test]
fn failure_preservation_uses_failure_head_tail() {
let long_stdout: String = (0..50)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["git".to_owned(), "status".to_owned()]),
stdout: Some(long_stdout),
exit_code: Some(1),
..Default::default()
};
let rules = load_builtin_rules();
let result = reduce_execution_with_rules(input.clone(), &rules, &ReduceOptions::default());
assert!(!result.inline_text.is_empty());
}
#[test]
fn success_uses_summarize_head_tail() {
let long_stdout: String = (0..50)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["git".to_owned(), "status".to_owned()]),
stdout: Some(long_stdout),
exit_code: Some(0),
..Default::default()
};
let rules = load_builtin_rules();
let ok_result = reduce_execution_with_rules(input, &rules, &ReduceOptions::default());
assert!(!ok_result.inline_text.is_empty());
}
#[test]
fn git_status_rewrites_modified() {
let stdout = "On branch main\n\
Changes not staged for commit:\n\
\tmodified: src/foo.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("M: src/foo.rs"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_rewrites_new_file() {
let stdout = "Changes to be committed:\n\
\tnew file: src/bar.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("A: src/bar.rs"),
"got: {}",
result.inline_text
);
}
#[test]
fn raw_mode_returns_unmodified() {
let input = make_input("bash", &["git", "status"], "unchanged text");
let rules = load_builtin_rules();
let opts = ReduceOptions {
raw: Some(true),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert_eq!(result.inline_text, "unchanged text");
assert_eq!(result.stats.ratio, 1.0);
}
#[test]
fn inline_text_respects_max_inline_chars() {
let long: String = "x\n".repeat(1000);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some(long),
..Default::default()
};
let rules = load_builtin_rules();
let opts = ReduceOptions {
max_inline_chars: Some(200),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(
count_text_chars(&result.inline_text) <= 300,
"inline_text too long: {} chars",
count_text_chars(&result.inline_text)
);
}
#[test]
fn tokenize_backslash_escape() {
let toks = tokenize_command(r"echo hello\ world");
assert_eq!(toks, vec!["echo", "hello world"]);
}
#[test]
fn tokenize_trailing_backslash() {
let toks = tokenize_command("echo hello\\");
assert_eq!(toks, vec!["echo", "hello\\"]);
}
#[test]
fn tokenize_single_quote() {
let toks = tokenize_command("echo 'hello world'");
assert_eq!(toks, vec!["echo", "hello world"]);
}
#[test]
fn tokenize_empty_string() {
assert!(tokenize_command("").is_empty());
assert!(tokenize_command(" ").is_empty());
}
#[test]
fn normalize_fills_argv_from_command() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
command: Some("git status --short".to_owned()),
argv: None,
..Default::default()
};
let out = normalize_execution_input(input);
let argv: Vec<&str> = out
.argv
.as_ref()
.unwrap()
.iter()
.map(String::as_str)
.collect();
assert_eq!(argv, vec!["git", "status", "--short"]);
}
#[test]
fn normalize_skips_when_argv_present() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
command: Some("ignored command".to_owned()),
argv: Some(vec!["git".to_owned(), "log".to_owned()]),
..Default::default()
};
let out = normalize_execution_input(input);
let argv: Vec<&str> = out
.argv
.as_ref()
.unwrap()
.iter()
.map(String::as_str)
.collect();
assert_eq!(argv, vec!["git", "log"]);
}
#[test]
fn normalize_no_op_when_empty_command() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
command: Some(String::new()),
argv: None,
..Default::default()
};
let out = normalize_execution_input(input);
assert!(out.argv.is_none() || out.argv.as_ref().map(|v| v.is_empty()).unwrap_or(true));
}
#[test]
fn cat_is_file_content_inspection() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["cat".to_owned(), "foo.txt".to_owned()]),
..Default::default()
};
assert!(is_file_content_inspection_command(&input));
}
#[test]
fn jq_is_file_content_inspection() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec![
"jq".to_owned(),
".".to_owned(),
"file.json".to_owned(),
]),
..Default::default()
};
assert!(is_file_content_inspection_command(&input));
}
#[test]
fn git_is_not_file_content_inspection() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["git".to_owned(), "status".to_owned()]),
..Default::default()
};
assert!(!is_file_content_inspection_command(&input));
}
#[test]
fn empty_argv_is_not_file_content_inspection() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec![]),
..Default::default()
};
assert!(!is_file_content_inspection_command(&input));
}
#[test]
fn file_inspection_command_with_path_prefix() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["/usr/bin/cat".to_owned(), "foo.txt".to_owned()]),
..Default::default()
};
assert!(is_file_content_inspection_command(&input));
}
#[test]
fn combined_text_takes_priority() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some("stdout data".to_owned()),
stderr: Some("stderr data".to_owned()),
combined_text: Some("combined!".to_owned()),
..Default::default()
};
let result = run(input);
assert!(result.inline_text.contains("combined!"));
}
#[test]
fn only_stderr_used_when_stdout_empty() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some(String::new()),
stderr: Some("error output".to_owned()),
..Default::default()
};
let result = run(input);
assert!(result.inline_text.contains("error output"));
}
#[test]
fn both_stdout_and_stderr_combined() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some("stdout line".to_owned()),
stderr: Some("stderr line".to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("stdout line") || result.inline_text.contains("stderr line")
);
}
#[test]
fn git_status_rewrites_deleted() {
let stdout = "Changes not staged for commit:\n\
\tdeleted: src/old.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("D: src/old.rs"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_rewrites_renamed() {
let stdout = "Changes to be committed:\n\
\trenamed: old.rs -> new.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("R:"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_rewrites_untracked_question_marks() {
let stdout = "Untracked files:\n\t\tfoo.txt\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("??"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_on_branch_line_removed() {
let stdout = "On branch main\nnothing to commit, working tree clean\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
!result.inline_text.contains("On branch"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_section_headers_shortened() {
let stdout = "Changes not staged for commit:\n\tmodified: foo.rs\n\
Changes to be committed:\n\tnew file: bar.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("Staged changes:")
|| result.inline_text.contains("Changes not staged:"),
"got: {}",
result.inline_text
);
}
#[test]
fn cat_command_passes_through_unchanged() {
let content = "line1\nline2\nline3\n";
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["cat".to_owned(), "foo.txt".to_owned()]),
stdout: Some(content.to_owned()),
..Default::default()
};
let rules = load_builtin_rules();
let result = reduce_execution_with_rules(input, &rules, &ReduceOptions::default());
assert_eq!(result.stats.ratio, 1.0);
}
#[test]
fn non_zero_exit_with_preserve_shows_more_lines() {
let long_output: String = (0..60)
.map(|i| format!("test line {}", i))
.collect::<Vec<_>>()
.join("\n");
let pass_input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "test".to_owned()]),
stdout: Some(long_output.clone()),
exit_code: Some(0),
..Default::default()
};
let fail_input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "test".to_owned()]),
stdout: Some(long_output),
exit_code: Some(1),
..Default::default()
};
let rules = load_builtin_rules();
let pass_result = reduce_execution_with_rules(pass_input, &rules, &ReduceOptions::default());
let fail_result = reduce_execution_with_rules(fail_input, &rules, &ReduceOptions::default());
assert!(!fail_result.inline_text.is_empty());
assert!(!pass_result.inline_text.is_empty());
}
#[test]
fn classifier_option_forces_rule() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["something".to_owned()]),
stdout: Some("output".to_owned()),
..Default::default()
};
let rules = load_builtin_rules();
let opts = ReduceOptions {
classifier: Some("git/status".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("git/status")
);
}
#[test]
fn stats_raw_chars_measured_for_empty_output() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some(String::new()),
stderr: Some(String::new()),
..Default::default()
};
let result = run(input);
assert_eq!(result.stats.raw_chars, 0);
assert_eq!(result.stats.ratio, 1.0);
}
#[test]
fn counter_counts_matching_lines() {
let stdout = "file.rs:10: found error\nfile.rs:20: another issue\nno match here\n";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["grep".to_owned(), "-r".to_owned(), "error".to_owned()]),
stdout: Some(stdout.to_owned()),
..Default::default()
};
let result = run(input);
if let Some(facts) = &result.facts {
assert!(facts.contains_key("match"), "expected 'match' counter");
}
}
#[test]
fn match_output_pattern_returns_canned_message() {
use crate::{
rules::compiler::compile_rule,
types::{RuleMatch, RuleOutputMatch},
};
let rule = crate::types::JsonRule {
id: "test/match-output".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: Some(vec![RuleOutputMatch {
pattern: "nothing to commit".to_owned(),
message: "Clean working tree".to_owned(),
flags: None,
}]),
counter_source: None,
r#match: RuleMatch::default(),
filters: None,
transforms: None,
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/match-output".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["git".to_owned(), "status".to_owned()]),
stdout: Some("nothing to commit, working tree clean".to_owned()),
..Default::default()
};
let rules = vec![
compiled,
load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap(),
];
let opts = ReduceOptions {
classifier: Some("test/match-output".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert_eq!(result.inline_text, "Clean working tree");
}
#[test]
fn on_empty_returns_custom_message() {
use crate::{rules::compiler::compile_rule, types::RuleMatch};
let rule = crate::types::JsonRule {
id: "test/on-empty".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: Some("(nothing here)".to_owned()),
match_output: None,
counter_source: None,
r#match: RuleMatch::default(),
filters: Some(crate::types::RuleFilters {
skip_patterns: Some(vec![".*".to_owned()]),
keep_patterns: None,
}),
transforms: None,
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/on-empty".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["something".to_owned()]),
stdout: Some("some output that gets filtered out".to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/on-empty".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert_eq!(result.inline_text, "(nothing here)");
}
#[test]
fn pretty_print_json_transform_works() {
use crate::{rules::compiler::compile_rule, types::RuleMatch};
let rule = crate::types::JsonRule {
id: "test/pretty-json".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: None,
counter_source: None,
r#match: RuleMatch::default(),
filters: None,
transforms: Some(crate::types::RuleTransforms {
pretty_print_json: Some(true),
strip_ansi: None,
trim_empty_edges: None,
dedupe_adjacent: None,
}),
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/pretty-json".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["jq".to_owned()]),
stdout: Some(r#"{"key":"value","num":42}"#.to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/pretty-json".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(
result.inline_text.contains('\n') || result.inline_text.contains("key"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_pr_list_json_output_compacted() {
let json_line =
r#"{"number":42,"title":"Fix the bug","state":"open","headRefName":"fix/issue-42"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#42"),
"got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("Fix the bug"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_table_format_fallback() {
let table_output = "42 Fix the bug open fix/issue-42 2024-01-01\n123 Another PR closed main 2024-01-02";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(table_output.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#42") || result.inline_text.contains("Fix the bug"),
"got: {}",
result.inline_text
);
}
#[test]
fn keep_patterns_filter_lines() {
use crate::{rules::compiler::compile_rule, types::RuleMatch};
let rule = crate::types::JsonRule {
id: "test/keep".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: None,
counter_source: None,
r#match: RuleMatch::default(),
filters: Some(crate::types::RuleFilters {
skip_patterns: None,
keep_patterns: Some(vec!["ERROR".to_owned()]),
}),
transforms: None,
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/keep".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_cmd".to_owned()]),
stdout: Some("INFO: all good\nERROR: something failed\nDEBUG: verbose".to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/keep".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(
result.inline_text.contains("ERROR"),
"got: {}",
result.inline_text
);
assert!(
!result.inline_text.contains("INFO"),
"got: {}",
result.inline_text
);
}
#[test]
fn counter_source_pre_keep_counts_before_filtering() {
use crate::{
rules::compiler::compile_rule,
types::{CounterSource, RuleCounter, RuleMatch},
};
let rule = crate::types::JsonRule {
id: "test/pre-keep".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: None,
counter_source: Some(CounterSource::PreKeep),
r#match: RuleMatch::default(),
filters: Some(crate::types::RuleFilters {
skip_patterns: None,
keep_patterns: Some(vec!["KEEP".to_owned()]),
}),
transforms: None,
summarize: None,
counters: Some(vec![RuleCounter {
name: "error".to_owned(),
pattern: "ERROR".to_owned(),
flags: None,
}]),
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/pre-keep".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_cmd".to_owned()]),
stdout: Some("ERROR: issue1\nERROR: issue2\nKEEP: this line".to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/pre-keep".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
if let Some(facts) = &result.facts {
let error_count = facts.get("error").copied().unwrap_or(0);
assert_eq!(error_count, 2, "pre-keep should count 2 errors");
}
}
#[test]
fn help_family_uses_middle_clamping() {
let long_help: String = "USAGE: tool [OPTIONS]\n".to_owned()
+ &" --option-N Description of option N\n".repeat(200);
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["tool".to_owned(), "--help".to_owned()]),
stdout: Some(long_help),
..Default::default()
};
let rules = load_builtin_rules();
let opts = ReduceOptions {
max_inline_chars: Some(400),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(
count_text_chars(&result.inline_text) <= 500,
"inline_text too long: {} chars",
count_text_chars(&result.inline_text)
);
}
#[test]
fn git_status_family_returns_compact_text_directly() {
let stdout = "M: src/foo.rs\nA: src/bar.rs\n";
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["git".to_owned(), "status".to_owned()]),
stdout: Some(stdout.to_owned()),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty());
}
#[test]
fn tiny_output_returns_passthrough() {
let tiny = "ok";
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_cmd".to_owned()]),
stdout: Some(tiny.to_owned()),
..Default::default()
};
let result = run(input);
assert_eq!(result.inline_text, "ok");
}
#[test]
fn passthrough_with_nonzero_exit_prefixes_exit_code() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["unknown_tool".to_owned()]),
stdout: Some("tiny output".to_owned()),
exit_code: Some(2),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("exit 2"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_with_labels_and_comments() {
let json_line = r#"{"number":7,"title":"Add feature","state":"open","headRefName":"feat/x","labels":[{"name":"enhancement"},{"name":"help wanted"}],"comments":{"totalCount":3},"updatedAt":"2024-01-15T10:00:00Z"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "issue".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#7"),
"got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("Add feature"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_with_display_title_and_database_id() {
let json_line = r#"{"databaseId":999,"displayTitle":"My Workflow Run","status":"completed","conclusion":"success","headBranch":"main"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "run".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#999") || result.inline_text.contains("My Workflow Run"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_empty_lines_returns_empty() {
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(" \n \n".to_owned()),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty() || result.inline_text.is_empty());
}
#[test]
fn gh_table_empty_line_returns_empty_string() {
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(" \n42 Fix bug open feat/fix 2024-01-01\n".to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#42") || result.inline_text.contains("Fix bug"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_table_three_columns_context() {
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some("99 My PR open\n".to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#99") || result.inline_text.contains("My PR"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_table_non_numeric_first_column() {
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "issue".to_owned(), "list".to_owned()]),
stdout: Some("feature My Issue open\n".to_owned()),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty());
}
#[test]
fn gh_json_comment_count_as_array() {
let json_line = r#"{"number":5,"title":"PR Title","state":"open","comments":[{"body":"comment1"},{"body":"comment2"}]}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#5"),
"got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("2c"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_comment_count_as_number() {
let json_line = r#"{"number":6,"title":"Another PR","state":"closed","comments":4}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#6"),
"got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("4c"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_labels_as_string_array() {
let json_line =
r#"{"number":8,"title":"Tagged PR","state":"open","labels":["bug","urgent",""]}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#8"),
"got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("bug") || result.inline_text.contains("{"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_labels_non_array_is_ignored() {
let json_line = r#"{"number":9,"title":"PR no labels","state":"open","labels":"bug"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("#9"),
"got: {}",
result.inline_text
);
}
#[test]
fn pretty_print_json_array_output() {
use crate::{rules::compiler::compile_rule, types::RuleMatch};
let rule = crate::types::JsonRule {
id: "test/ppjson-arr".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: None,
counter_source: None,
r#match: RuleMatch::default(),
filters: None,
transforms: Some(crate::types::RuleTransforms {
pretty_print_json: Some(true),
strip_ansi: None,
trim_empty_edges: None,
dedupe_adjacent: None,
}),
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/ppjson-arr".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some(r#"[1,2,3]"#.to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/ppjson-arr".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(!result.inline_text.is_empty());
}
#[test]
fn pretty_print_json_non_json_passthrough() {
use crate::{rules::compiler::compile_rule, types::RuleMatch};
let rule = crate::types::JsonRule {
id: "test/ppjson-plain".to_owned(),
family: "test".to_owned(),
description: None,
priority: None,
on_empty: None,
match_output: None,
counter_source: None,
r#match: RuleMatch::default(),
filters: None,
transforms: Some(crate::types::RuleTransforms {
pretty_print_json: Some(true),
strip_ansi: None,
trim_empty_edges: None,
dedupe_adjacent: None,
}),
summarize: None,
counters: None,
failure: None,
};
let compiled = compile_rule(
rule,
crate::types::RuleOrigin::Builtin,
"builtin:test/ppjson-plain".to_owned(),
);
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some("plain text output".to_owned()),
..Default::default()
};
let fb = load_builtin_rules()
.into_iter()
.find(|r| r.rule.id == "generic/fallback")
.unwrap();
let rules = vec![compiled, fb];
let opts = ReduceOptions {
classifier: Some("test/ppjson-plain".to_owned()),
..Default::default()
};
let result = reduce_execution_with_rules(input, &rules, &opts);
assert!(result.inline_text.contains("plain text output"));
}
#[test]
fn normalize_whitespace_only_command_returns_no_argv() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
command: Some("''".to_owned()), argv: None,
..Default::default()
};
let out = normalize_execution_input(input);
assert!(
out.argv.as_ref().map(|v| v.is_empty()).unwrap_or(true),
"expected empty or no argv"
);
}
#[test]
fn select_inline_text_passthrough_shorter_than_compact() {
let short_output = "short";
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: Some(short_output.to_owned()),
..Default::default()
};
let result = run(input);
assert_eq!(result.inline_text, "short");
}
#[test]
fn zero_raw_chars_ratio_is_one() {
let input = ToolExecutionInput {
tool_name: "bash".to_owned(),
argv: Some(vec!["some_tool".to_owned()]),
stdout: None,
stderr: None,
..Default::default()
};
let result = run(input);
assert_eq!(result.stats.ratio, 1.0);
assert_eq!(result.stats.raw_chars, 0);
}
#[test]
fn gh_json_workflow_name_field() {
let json_line = r#"{"databaseId":100,"workflowName":"CI/CD Pipeline","status":"in_progress"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "run".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("CI/CD Pipeline") || result.inline_text.contains("#100"),
"got: {}",
result.inline_text
);
}
#[test]
fn gh_json_missing_title_falls_to_table_format() {
let json_line = r#"{"number":1,"state":"open"}"#;
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["gh".to_owned(), "pr".to_owned(), "list".to_owned()]),
stdout: Some(json_line.to_owned()),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty() || result.inline_text.is_empty());
}
#[test]
fn skip_patterns_remove_matching_lines() {
let stdout =
" Compiling foo v0.1.0\n Finished dev [unoptimized] target(s)\ntest foo ... ok\n";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "test".to_owned()]),
stdout: Some(stdout.to_owned()),
..Default::default()
};
let result = run(input);
assert!(
!result.inline_text.contains("Compiling"),
"got: {}",
result.inline_text
);
}
#[test]
fn search_family_includes_fact_counts() {
let output = "file.rs:10: match one\nfile.rs:20: match two\nfile.rs:30: match three\n";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["grep".to_owned(), "-r".to_owned(), "match".to_owned()]),
stdout: Some(output.to_owned()),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty());
}
#[test]
fn test_results_failure_includes_failed_count() {
let output = "test foo ... ok\ntest bar ... FAILED\ntest baz ... ok\nFAILED\n";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "test".to_owned()]),
stdout: Some(output.to_owned()),
exit_code: Some(1),
..Default::default()
};
let result = run(input);
assert!(!result.inline_text.is_empty());
}
#[test]
fn git_status_diverged_message_removed() {
let stdout = "On branch main\nYour branch and 'origin/main' have diverged,\nand have 2 and 3 different commits each.\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
!result.inline_text.contains("and have"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_empty_line_handled() {
let stdout = "Changes not staged for commit:\n\n\tmodified: foo.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("M: foo.rs"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_no_changes_hint_removed() {
let stdout = "nothing added to commit but untracked files present (use \"git add\" to track)\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
!result
.inline_text
.contains("nothing added to commit but untracked"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_use_git_hint_removed() {
let stdout = "(use \"git add <file>...\" to update what will be committed)\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
!result.inline_text.contains("use \"git add"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_porcelain_format_mm_code() {
let stdout = "MM src/foo.rs\nA src/bar.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(!result.inline_text.is_empty());
}
#[test]
fn git_status_consecutive_empty_lines_collapsed() {
let stdout = "Changes not staged for commit:\n\n\n\tmodified: a.rs\n\n\n\tmodified: b.rs\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
result.inline_text.contains("M: a.rs"),
"got: {}",
result.inline_text
);
}
#[test]
fn git_status_no_changes_to_commit() {
let stdout = "no changes added to commit (use \"git add\" and/or \"git commit -a\")\n";
let input = make_input("bash", &["git", "status"], stdout);
let result = run(input);
assert!(
!result.inline_text.contains("no changes added to commit"),
"got: {}",
result.inline_text
);
}
#[test]
fn head_tail_zero_head() {
use crate::text::head_tail;
let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
let result = head_tail(&lines, 0, 2);
assert_eq!(result.len(), 3); assert!(result[0].contains("omitted"));
}
#[test]
fn head_tail_zero_tail() {
use crate::text::head_tail;
let lines: Vec<String> = (0..5).map(|i| format!("line{}", i)).collect();
let result = head_tail(&lines, 2, 0);
assert_eq!(result.len(), 3);
}
#[test]
fn head_tail_n_greater_than_line_count() {
use crate::text::head_tail;
let lines: Vec<String> = (0..3).map(|i| format!("line{}", i)).collect();
let result = head_tail(&lines, 5, 5);
assert_eq!(result, lines);
}
#[test]
fn cargo_clippy_strips_compiling_preserves_warnings() {
let stdout = "\
Compiling serde v1.0.200
Compiling serde_json v1.0.120
Compiling openhuman v0.1.0 (/home/user/project)
Checking openhuman v0.1.0 (/home/user/project)
warning: unused variable: `x`
--> src/main.rs:10:9
|
10 | let x = 42;
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
= note: `#[warn(unused_variables)]` on by default
warning: unused import: `std::collections::HashMap`
--> src/lib.rs:3:5
|
3 | use std::collections::HashMap;
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` on by default
warning: `openhuman` (lib) generated 2 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.32s
";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "clippy".to_owned()]),
stdout: Some(stdout.to_owned()),
exit_code: Some(0),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("unused variable"),
"clippy warning text must be preserved, got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("unused import"),
"clippy warning text must be preserved, got: {}",
result.inline_text
);
assert!(
!result.inline_text.contains("Compiling serde v1.0.200"),
"Compiling lines should be stripped, got: {}",
result.inline_text
);
assert!(
!result.inline_text.contains("Checking openhuman"),
"Checking lines should be stripped, got: {}",
result.inline_text
);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("lint/cargo-clippy")
);
assert_eq!(result.classification.family, "lint-results");
if let Some(facts) = &result.facts
&& let Some(&warning_count) = facts.get("warning")
{
assert!(
warning_count >= 2,
"expected at least 2 warnings counted, got {}",
warning_count
);
}
}
#[test]
fn cargo_build_failure_preserves_errors() {
let stdout = "\
Compiling serde v1.0.200
Compiling serde_json v1.0.120
Compiling openhuman v0.1.0 (/home/user/project)
error[E0308]: mismatched types
--> src/main.rs:15:20
|
15 | let x: u32 = \"hello\";
| --- ^^^^^^^ expected `u32`, found `&str`
| |
| expected due to this
error[E0425]: cannot find value `undefined_var` in this scope
--> src/main.rs:20:5
|
20 | undefined_var
| ^^^^^^^^^^^^^ not found in this scope
error: aborting due to 2 previous errors
Some errors have detailed explanations: E0308, E0425.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `openhuman` (bin \"openhuman\") due to 2 previous errors
";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "build".to_owned()]),
stdout: Some(stdout.to_owned()),
exit_code: Some(101),
..Default::default()
};
let result = run(input);
assert!(
result.inline_text.contains("mismatched types"),
"error diagnostic must be preserved, got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("cannot find value"),
"error diagnostic must be preserved, got: {}",
result.inline_text
);
assert!(
!result.inline_text.contains("Compiling serde v1.0.200"),
"Compiling lines should be stripped, got: {}",
result.inline_text
);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("build/cargo-build")
);
assert_eq!(result.classification.family, "build-rust");
if let Some(facts) = &result.facts
&& let Some(&error_count) = facts.get("error")
{
assert!(
error_count >= 2,
"expected at least 2 errors counted, got {}",
error_count
);
}
}
#[test]
fn cargo_check_classifies_as_cargo_build() {
let stdout = "\
Checking serde v1.0.200
Checking openhuman v0.1.0 (/home/user/project)
warning: unused variable: `y`
--> src/lib.rs:5:9
|
5 | let y = 10;
| ^
warning: `openhuman` (lib) generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.23s
";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "check".to_owned()]),
stdout: Some(stdout.to_owned()),
exit_code: Some(0),
..Default::default()
};
let result = run(input);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("build/cargo-build"),
"cargo check should classify as build/cargo-build"
);
assert!(
!result.inline_text.contains("Checking serde"),
"Checking lines should be stripped, got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("unused variable"),
"warnings must be preserved, got: {}",
result.inline_text
);
}
#[test]
fn cargo_fmt_check_preserves_diff_hunks() {
let stdout = "\
Diff in /home/user/project/src/main.rs at line 5:
- let x=42;
+ let x = 42;
Diff in /home/user/project/src/lib.rs at line 10:
-fn foo(){
+fn foo() {
";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec![
"cargo".to_owned(),
"fmt".to_owned(),
"--check".to_owned(),
]),
stdout: Some(stdout.to_owned()),
exit_code: Some(1),
..Default::default()
};
let result = run(input);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("lint/cargo-fmt"),
"cargo fmt should classify as lint/cargo-fmt"
);
assert_eq!(result.classification.family, "lint-results");
assert!(
result.inline_text.contains("Diff in"),
"Diff in lines must be preserved, got: {}",
result.inline_text
);
if let Some(facts) = &result.facts
&& let Some(&file_count) = facts.get("unformatted file")
{
assert_eq!(
file_count, 2,
"expected 2 unformatted files counted, got {}",
file_count
);
}
}
#[test]
fn cargo_doc_strips_documenting_noise() {
let stdout = "\
Compiling serde v1.0.200
Compiling openhuman v0.1.0 (/home/user/project)
Documenting openhuman v0.1.0 (/home/user/project)
warning: missing documentation for a public function
--> src/lib.rs:10:1
|
10 | pub fn undocumented() {}
| ^^^^^^^^^^^^^^^^^^^^^
warning: unresolved link to `NonExistent`
--> src/lib.rs:5:10
|
5 | /// See [`NonExistent`]
| ^^^^^^^^^^^^^ no item named `NonExistent` in scope
Finished `doc` profile [unoptimized + debuginfo] target(s) in 3.45s
";
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "doc".to_owned()]),
stdout: Some(stdout.to_owned()),
exit_code: Some(0),
..Default::default()
};
let result = run(input);
assert_eq!(
result.classification.matched_reducer.as_deref(),
Some("build/cargo-doc"),
"cargo doc should classify as build/cargo-doc"
);
assert_eq!(result.classification.family, "build-rust");
assert!(
!result.inline_text.contains("Documenting openhuman"),
"Documenting lines should be stripped, got: {}",
result.inline_text
);
assert!(
!result.inline_text.contains("Compiling serde"),
"Compiling lines should be stripped, got: {}",
result.inline_text
);
assert!(
result.inline_text.contains("missing documentation"),
"doc warnings must be preserved, got: {}",
result.inline_text
);
}
#[test]
fn cargo_build_success_compacts_output() {
let mut lines = Vec::new();
for i in 0..80 {
lines.push(format!(" Compiling dep-{} v0.{}.0", i, i));
}
lines.push(" Compiling my-project v0.1.0 (/home/user/project)".to_owned());
lines.push(
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s".to_owned(),
);
let stdout = lines.join("\n");
let input = ToolExecutionInput {
tool_name: "exec".to_owned(),
argv: Some(vec!["cargo".to_owned(), "build".to_owned()]),
stdout: Some(stdout.clone()),
exit_code: Some(0),
..Default::default()
};
let result = run(input);
assert!(
!result.inline_text.contains("Compiling dep-"),
"Compiling lines should be stripped, got: {}",
result.inline_text
);
assert!(
result.stats.reduced_chars < result.stats.raw_chars,
"expected compaction: reduced={} raw={}",
result.stats.reduced_chars,
result.stats.raw_chars
);
}