use super::*;
use crate::learn_utils::{strip_fences as strip_fences_impl, truncate_utf8};
fn strip_fences(s: &str) -> String {
strip_fences_impl(s)
}
#[cfg(test)]
#[path = "learn_validate_tests.rs"]
mod validate;
#[test]
fn test_strip_fences_toml() {
let input = "```toml\ncommand_match = \"test\"\n```";
assert_eq!(strip_fences(input), "command_match = \"test\"");
}
#[test]
fn test_strip_fences_plain() {
let input = "```\ncommand_match = \"test\"\n```";
assert_eq!(strip_fences(input), "command_match = \"test\"");
}
#[test]
fn test_strip_fences_none() {
let input = "command_match = \"test\"";
assert_eq!(strip_fences(input), "command_match = \"test\"");
}
#[test]
fn test_strip_fences_whitespace_preserved() {
let input = "```toml\n\ncommand_match = \"test\"\n\n```";
let result = strip_fences(input);
assert!(
result.contains("command_match"),
"content must be preserved"
);
}
#[test]
fn test_validate_pattern_toml_regexes_valid() {
let toml = r#"
command_match = "^mytest"
[success]
pattern = '(?P<n>\d+) passed'
summary = "{n} passed"
"#;
assert!(validate_pattern_toml_with_limits(toml).is_ok());
}
#[test]
fn test_validate_pattern_toml_regexes_invalid_regex() {
let toml = r#"
command_match = "[invalid"
"#;
assert!(validate_pattern_toml_with_limits(toml).is_err());
}
#[test]
fn test_validate_pattern_toml_regexes_no_success() {
let toml = r#"command_match = "^cargo""#;
assert!(validate_pattern_toml_with_limits(toml).is_ok());
}
#[test]
fn test_validate_pattern_toml_regexes_invalid_toml_syntax() {
let toml = "this is not valid = [toml";
assert!(validate_pattern_toml_with_limits(toml).is_err());
}
#[test]
fn test_validate_pattern_toml_regexes_missing_command_match() {
let toml = r#"
[success]
pattern = "ok"
summary = "done"
"#;
assert!(validate_pattern_toml_with_limits(toml).is_err());
}
#[test]
fn test_validate_pattern_toml_regexes_invalid_command_match_regex() {
let toml = r#"command_match = "[invalid_regex""#;
assert!(validate_pattern_toml_with_limits(toml).is_err());
}
#[test]
fn test_validate_pattern_toml_regexes_invalid_success_pattern_regex() {
let toml = r#"
command_match = "^cargo"
[success]
pattern = "[invalid"
summary = "done"
"#;
assert!(validate_pattern_toml_with_limits(toml).is_err());
}
#[test]
fn test_validate_pattern_toml_regexes_with_valid_success_pattern() {
let toml = "command_match = \"^pytest\"\n[success]\npattern = '(?P<n>\\d+) passed'\nsummary = \"{n} passed\"";
assert!(validate_pattern_toml_with_limits(toml).is_ok());
}
#[test]
fn test_validate_pattern_toml_regexes_enforces_length_limit() {
let long_regex = format!(r#"command_match = "{}""#, "a".repeat(501));
assert!(validate_pattern_toml_with_limits(&long_regex).is_err());
}
#[test]
fn test_truncate_for_prompt() {
let short = "hello";
assert_eq!(truncate_for_prompt(short), "hello");
let long = "x".repeat(5000);
assert_eq!(truncate_for_prompt(&long).len(), 4000);
}
#[test]
fn test_truncate_for_prompt_boundary() {
let exact = "a".repeat(4000);
assert_eq!(truncate_for_prompt(&exact).len(), 4000);
let over = "a".repeat(4001);
assert_eq!(truncate_for_prompt(&over).len(), 4000);
}
#[test]
fn test_truncate_utf8_multibyte_boundary() {
let s = "££££"; let result = truncate_utf8(s, 5);
assert_eq!(result.len(), 4);
assert!(result.is_ascii() || std::str::from_utf8(result.as_bytes()).is_ok());
assert_eq!(result, "££");
}
#[test]
fn test_truncate_utf8_exact_boundary() {
let s = "hello"; assert_eq!(truncate_utf8(s, 5), "hello");
assert_eq!(truncate_utf8(s, 10), "hello");
}
#[test]
fn test_label_sanitizes_first_word_special_chars() {
assert_eq!(label("../bin/cargo test"), "cargo-test");
assert_eq!(label("./binary/name test"), "name-test");
assert_eq!(label("/path/.hidden/bin test"), "bin-test");
assert_eq!(label("cmd-with-dashes test"), "cmd-with-dashes-test");
}
#[test]
fn test_label_first_word_length_limit() {
let long_first = "a".repeat(100);
let result = label(&format!("{long_first} test"));
assert_eq!(
result.len(),
55,
"first word truncated to 50 chars + hyphen + 4 chars for 'test'"
);
assert_eq!(
result
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.count(),
result.len()
);
}
#[test]
fn test_label_empty_after_sanitization() {
assert_eq!(label("!!! test"), "unknown");
assert_eq!(label("../ test"), "unknown");
assert_eq!(label("@@@ test"), "unknown");
}
#[test]
fn test_label_prevents_dotfile_creation() {
assert_eq!(label(".cargo test"), "cargo-test");
assert_eq!(label("..cargo test"), "cargo-test");
assert_eq!(label("/path/.hidden/test"), "test");
assert_eq!(label("./hidden/bin/test"), "test");
}
#[test]
fn test_label_extraction() {
assert_eq!(label("pytest -x"), "pytest");
assert_eq!(label("/usr/bin/cargo test"), "cargo-test");
}
#[test]
fn test_label_path_extraction() {
assert_eq!(label("/usr/local/bin/rustc"), "rustc");
assert_eq!(label("./target/release/oo"), "oo");
}
#[test]
fn test_label_empty_command() {
assert_eq!(label(""), "unknown");
}
#[test]
fn test_label_subcommand_included() {
assert_eq!(label("cargo fmt --check"), "cargo-fmt");
assert_eq!(label("cargo clippy -- -D warnings"), "cargo-clippy");
assert_eq!(label("npm run build"), "npm-run");
assert_eq!(label("cargo test"), "cargo-test");
}
#[test]
fn test_label_flag_excluded() {
assert_eq!(label("pytest -x"), "pytest");
assert_eq!(label("cargo --verbose test"), "cargo");
}
#[test]
fn test_label_sanitizes_unsafe_chars_in_second_word() {
assert_eq!(label("git some/path/arg"), "git-somepatharg");
assert_eq!(label("cargo /absolute/path"), "cargo-absolutepath");
assert_eq!(label("git subcommand=val"), "git-subcommandval");
assert_eq!(label("cmd ../etc/passwd"), "cmd-etcpasswd");
assert_eq!(label("rustc --foo --bar"), "rustc");
}
#[test]
fn test_label_second_word_length_limit() {
let long_second = "a".repeat(100);
let result = label(&format!("cargo {long_second}"));
assert_eq!(
result.len(),
56,
"'cargo-' (6 chars) + second word truncated to 50 chars = 56"
);
}
#[test]
fn test_default_config_has_valid_fields() {
let config = LearnConfig::default();
assert!(!config.provider.is_empty(), "provider must not be empty");
assert!(!config.model.is_empty(), "model must not be empty");
assert!(
!config.api_key_env.is_empty(),
"api_key_env must not be empty"
);
}
#[test]
fn test_detect_provider_no_keys_defaults_to_anthropic() {
let config = detect_provider_with(|_| None);
assert_eq!(config.provider, "anthropic");
assert_eq!(config.api_key_env, "ANTHROPIC_API_KEY");
assert_eq!(config.model, "claude-haiku-4-5");
}
#[test]
fn test_detect_provider_anthropic_model_name() {
let config = detect_provider_with(|key| {
if key == "ANTHROPIC_API_KEY" {
Some("test-key".into())
} else {
None
}
});
assert_eq!(config.provider, "anthropic");
assert_eq!(
config.model, "claude-haiku-4-5",
"Anthropic model must use the stable alias, not the dated snapshot"
);
}
#[test]
fn test_load_learn_config_no_file_returns_default() {
match load_learn_config() {
Ok(c) => {
assert!(!c.provider.is_empty());
assert!(!c.model.is_empty());
assert!(!c.api_key_env.is_empty());
}
Err(_) => {} }
}
#[test]
fn test_patterns_dir_is_under_config_dir() {
let dir = patterns_dir();
let s = dir.to_string_lossy();
assert!(s.ends_with("oo/patterns"), "got: {s}");
}
#[test]
fn test_run_background_missing_file_returns_err() {
assert!(run_background("/tmp/__oo_no_such_file_xyz__.json").is_err());
}
#[test]
fn test_run_background_invalid_json_returns_err() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), b"not valid json {{{").expect("write");
assert!(run_background(tmp.path().to_str().expect("utf8 path")).is_err());
}
#[test]
fn test_run_background_valid_json_no_api_key() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let data = serde_json::json!({"command": "echo hello", "output": "hello", "exit_code": 0});
std::fs::write(tmp.path(), data.to_string()).expect("write");
let _ = run_background(tmp.path().to_str().expect("utf8 path"));
}
#[test]
fn test_anthropic_url_validation() {
assert!(validate_anthropic_url("https://api.anthropic.com/v1/messages").is_ok());
assert!(validate_anthropic_url("https://custom.example.com/api").is_ok());
assert!(validate_anthropic_url("http://localhost:8000/api").is_ok());
assert!(validate_anthropic_url("http://localhost/api").is_ok());
assert!(validate_anthropic_url("http://127.0.0.1:8000/api").is_ok());
assert!(validate_anthropic_url("http://127.0.0.1/api").is_ok());
let result = validate_anthropic_url("http://api.example.com/v1/messages");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("ANTHROPIC_API_URL must use HTTPS"));
assert!(err_msg.contains("got: http://api.example.com/v1/messages"));
let result = validate_anthropic_url("http://192.168.1.1/api");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("got: http://192.168.1.1/api"));
let result = validate_anthropic_url("http://example.com");
assert!(result.is_err());
let result = validate_anthropic_url("http://localhost.evil.com/api");
assert!(result.is_err());
}
#[test]
fn test_run_background_with_hint_extracts_hint() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let data = serde_json::json!({
"command": "cargo test",
"output": "test result: ok. 5 passed\n",
"exit_code": 0,
"hint": "capture summary line only"
});
std::fs::write(tmp.path(), data.to_string()).expect("write");
let path = tmp.path();
let content = std::fs::read_to_string(path).expect("read");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json");
let hint = parsed["hint"].as_str();
assert_eq!(hint, Some("capture summary line only"));
}
#[test]
fn test_learn_status_written_on_failure() {
let dir = tempfile::TempDir::new().expect("tempdir");
let status_path = dir.path().join("learn-status.log");
crate::commands::write_learn_status_failure(&status_path, "cargo-test", "no API key set")
.expect("write must succeed");
let content = std::fs::read_to_string(&status_path).expect("read");
assert!(
content.starts_with("FAILED cargo-test:"),
"status must start with FAILED prefix: {content}"
);
assert!(
content.contains("no API key set"),
"status must contain error message: {content}"
);
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let data = serde_json::json!({"command": "echo hello", "output": "hello", "exit_code": 0});
std::fs::write(tmp.path(), data.to_string()).expect("write");
let _ = run_background(tmp.path().to_str().expect("utf8 path"));
}
#[test]
fn test_run_background_invalid_exit_code_truncation() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let data = serde_json::json!({
"command": "echo hello",
"output": "hello",
"exit_code": 2147483648i64
});
std::fs::write(tmp.path(), data.to_string()).expect("write");
let result = run_background(tmp.path().to_str().expect("utf8 path"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("exit_code out of range for i32"),
"error should mention i32 range: got {}",
err_msg
);
}
#[test]
fn test_run_background_negative_exit_code_out_of_range() {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let data = serde_json::json!({
"command": "echo hello",
"output": "hello",
"exit_code": -2147483649i64
});
std::fs::write(tmp.path(), data.to_string()).expect("write");
let result = run_background(tmp.path().to_str().expect("utf8 path"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("exit_code out of range for i32"),
"error should mention i32 range: got {}",
err_msg
);
}
#[test]
fn test_run_learn_with_config_rejects_hint_too_long() {
let tmpdir = tempfile::TempDir::new().expect("tempdir");
let config = LearnConfig {
provider: "anthropic".into(),
model: "claude-haiku-4-5".into(),
api_key_env: "ANTHROPIC_API_KEY".into(),
};
let status_path = tmpdir.path().join("status.log");
let params = LearnParams {
config: &config,
api_key: "test-key",
base_url: "https://api.anthropic.com/v1/messages",
patterns_dir: tmpdir.path(),
learn_status_path: &status_path,
hint: Some(&"x".repeat(1001)), };
let result = run_learn_with_config(¶ms, "echo test", "output", 0);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("--hint too long"),
"error should mention hint limit: got {}",
err_msg
);
assert!(
err_msg.contains("1000"),
"error should mention limit value: got {}",
err_msg
);
}
#[test]
fn test_run_learn_with_config_truncates_command() {
let tmpdir = tempfile::TempDir::new().expect("tempdir");
let config = LearnConfig {
provider: "anthropic".into(),
model: "claude-haiku-4-5".into(),
api_key_env: "ANTHROPIC_API_KEY".into(),
};
let long_command = "a".repeat(200); let status_path = tmpdir.path().join("status.log");
let params = LearnParams {
config: &config,
api_key: "test-key",
base_url: "https://api.anthropic.com/v1/messages",
patterns_dir: tmpdir.path(),
learn_status_path: &status_path,
hint: None,
};
let result = run_learn_with_config(¶ms, &long_command, "output", 0);
if let Err(e) = result {
let err_msg = e.to_string();
assert!(
!err_msg.contains("too long"),
"command should be truncated, not rejected: got {}",
err_msg
);
}
}
#[test]
fn test_truncate_utf8_multibyte_command_crosses_limit() {
let multibyte_command = "测试".repeat(50); let truncated = crate::learn_utils::truncate_utf8(&multibyte_command, 100);
assert_eq!(truncated.len(), 99);
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
assert!(multibyte_command.starts_with(truncated));
let emoji_command = "🎉".repeat(30); let truncated_emoji = crate::learn_utils::truncate_utf8(&emoji_command, 100);
assert_eq!(truncated_emoji.len(), 100);
assert!(std::str::from_utf8(truncated_emoji.as_bytes()).is_ok());
assert!(emoji_command.starts_with(truncated_emoji));
}