use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::fs;
use tempfile::TempDir;
#[allow(deprecated)]
fn cmd() -> Command {
Command::cargo_bin("toggle").unwrap()
}
fn setup_temp_file(content: &str, filename: &str) -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().join(filename);
fs::write(&path, content).unwrap();
(dir, path)
}
fn setup_temp_dir_with_files(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
#[test]
fn test_multiple_line_ranges_non_adjacent() {
let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\nf\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "2:3", "-l", "5:6"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "a\n# b\n# c\nd\n# e\n# f\n");
}
#[test]
fn test_multiple_line_ranges_overlapping() {
let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:3", "-l", "2:4"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "# a\n# b\n# c\n# d\ne\n");
}
#[test]
fn test_to_end_extends_to_eof() {
let (_dir, path) = setup_temp_file("a\nb\nc\nd\ne\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "3", "--to-end"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "a\nb\n# c\n# d\n# e\n");
}
#[test]
fn test_to_end_without_line_errors() {
let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "--to-end"])
.assert()
.failure()
.stderr(predicates::str::contains("--to-end requires"));
}
#[test]
fn test_multi_line_mode_wraps_in_block_comment() {
let (_dir, path) = setup_temp_file("line1\nline2\nline3\nline4\n", "test.js");
cmd()
.args([path.to_str().unwrap(), "-l", "2:3", "-m", "multi"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("/*"), "should contain block comment start");
assert!(result.contains("*/"), "should contain block comment end");
}
#[test]
fn test_multi_line_mode_unwraps_block_comment() {
let (_dir, path) = setup_temp_file("line1\n/* line2\nline3 */\nline4\n", "test.js");
cmd()
.args([path.to_str().unwrap(), "-l", "2:3", "-m", "multi"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(!result.contains("/*"), "should unwrap block comment start");
assert!(!result.contains("*/"), "should unwrap block comment end");
}
#[test]
fn test_multi_line_mode_unsupported_for_python() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "-m", "multi"])
.assert()
.failure()
.stderr(predicates::str::contains(
"Multi-line comments not supported",
));
}
#[test]
fn test_comment_style_single_override() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.txt");
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--comment-style", "//"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(
result.contains("// hello"),
"should use custom // delimiter"
);
}
#[test]
fn test_comment_style_with_multi_line() {
let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.txt");
cmd()
.args([
path.to_str().unwrap(),
"-l",
"1:2",
"-m",
"multi",
"--comment-style",
"//",
"/*",
"*/",
])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("/*"), "should use custom multi-line start");
assert!(result.contains("*/"), "should use custom multi-line end");
}
#[test]
fn test_comment_style_two_values_errors() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([
path.to_str().unwrap(),
"-l",
"1:1",
"--comment-style",
"//",
"/*",
])
.assert()
.failure()
.stderr(predicates::str::contains("--comment-style requires"));
}
#[test]
fn test_interactive_yes_modifies_file() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--interactive"])
.write_stdin("y\n")
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("#"), "file should be modified on 'y'");
}
#[test]
fn test_interactive_no_skips_modification() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
let original = fs::read_to_string(&path).unwrap();
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--interactive"])
.write_stdin("n\n")
.assert()
.success();
let after = fs::read_to_string(&path).unwrap();
assert_eq!(
original, after,
"file should not be modified when user answers 'n'"
);
}
#[test]
fn test_toggle_line_range_comments_lines() {
let (_dir, path) = setup_temp_file("line1\nline2\nline3\nline4\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "2:3"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("line1\n"));
assert!(result.contains("#"));
assert!(result.contains("line4\n"));
}
#[test]
fn test_toggle_force_on() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "-f", "on"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("#"));
}
#[test]
fn test_toggle_force_off() {
let (_dir, path) = setup_temp_file("# hello\n# world\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "-f", "off"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(!result.contains("#"));
}
#[test]
fn test_toggle_section() {
let content = "before\n# toggle:start ID=test\nhello\n# toggle:end ID=test\nafter\n";
let (_dir, path) = setup_temp_file(content, "test.py");
cmd()
.args([path.to_str().unwrap(), "-S", "test", "-f", "on"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("#hello") || result.contains("# hello"));
}
#[test]
fn test_strict_ext_rejects_non_py() {
let (_dir, path) = setup_temp_file("console.log('hi')\n", "test.js");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--strict-ext"])
.assert()
.failure();
}
#[test]
fn test_strict_ext_accepts_py() {
let (_dir, path) = setup_temp_file("print('hi')\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--strict-ext"])
.assert()
.success();
}
#[test]
fn test_verbose_outputs_to_stderr() {
let (_dir, path) = setup_temp_file("print('hi')\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "-v"])
.assert()
.success()
.stderr(predicates::str::contains("Processing"));
}
#[test]
fn test_toggle_javascript() {
let (_dir, path) = setup_temp_file("console.log('hi');\n", "test.js");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("//"));
}
#[test]
fn test_toggle_shell() {
let (_dir, path) = setup_temp_file("echo hello\n", "test.sh");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("#"));
}
#[test]
fn test_out_of_range_line_errors() {
let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "100:105"])
.assert()
.failure()
.stderr(predicates::str::contains("out of range"));
}
#[test]
fn test_nonexistent_file() {
cmd()
.args(["/tmp/nonexistent_file_toggle_test.py", "-l", "1:1"])
.assert()
.failure();
}
#[test]
fn test_dry_run_shows_diff_and_does_not_modify_file() {
let (_dir, path) = setup_temp_file("line1\nline2\nline3\n", "test.py");
let original = fs::read_to_string(&path).unwrap();
cmd()
.args([path.to_str().unwrap(), "-l", "2:3", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("---"))
.stdout(predicates::str::contains("+++"))
.stdout(predicates::str::contains("@@"));
let after = fs::read_to_string(&path).unwrap();
assert_eq!(
original, after,
"file should not be modified in dry-run mode"
);
}
#[test]
fn test_dry_run_no_changes_empty_stdout() {
let (_dir, path) = setup_temp_file("# already commented\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "-f", "on", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::is_empty().not().or(predicate::always()));
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, "# already commented\n");
}
#[test]
fn test_dry_run_with_verbose() {
let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--dry-run", "-v"])
.assert()
.success()
.stderr(predicates::str::contains("Processing"));
}
#[test]
fn test_backup_creates_backup_file() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
let original = fs::read_to_string(&path).unwrap();
cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--backup", ".bak"])
.assert()
.success();
let backup_path = path.with_file_name("test.py.bak");
assert!(backup_path.exists(), "backup file should exist");
let backup_content = fs::read_to_string(&backup_path).unwrap();
assert_eq!(
backup_content, original,
"backup should contain original content"
);
let modified = fs::read_to_string(&path).unwrap();
assert!(modified.contains("#"), "original file should be toggled");
}
#[test]
fn test_backup_with_dry_run_skips_backup() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
cmd()
.args([
path.to_str().unwrap(),
"-l",
"1:2",
"--backup",
".bak",
"--dry-run",
])
.assert()
.success();
let backup_path = path.with_file_name("test.py.bak");
assert!(
!backup_path.exists(),
"backup should not be created in dry-run mode"
);
}
#[test]
fn test_config_custom_delimiter() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(".toggleConfig");
fs::write(
&config_path,
r#"
[language.python]
single_line_delimiter = ";;"
"#,
)
.unwrap();
let file_path = dir.path().join("test.py");
fs::write(&file_path, "hello\nworld\n").unwrap();
cmd()
.args([
file_path.to_str().unwrap(),
"-l",
"1:2",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success();
let result = fs::read_to_string(&file_path).unwrap();
assert!(
result.contains(";;"),
"should use custom delimiter from config"
);
}
#[test]
fn test_config_cli_force_overrides_config() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(".toggleConfig");
fs::write(
&config_path,
r#"
[global]
force_state = "on"
"#,
)
.unwrap();
let file_path = dir.path().join("test.py");
fs::write(&file_path, "# commented\n").unwrap();
cmd()
.args([
file_path.to_str().unwrap(),
"-l",
"1:1",
"-f",
"off",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success();
let result = fs::read_to_string(&file_path).unwrap();
assert!(
!result.contains("#"),
"CLI --force off should override config force_state=on"
);
}
#[test]
fn test_config_nonexistent_file_errors() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([
path.to_str().unwrap(),
"-l",
"1:1",
"--config",
"/tmp/nonexistent_toggle_config",
])
.assert()
.failure();
}
#[test]
fn test_config_global_default_mode() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(".toggleConfig");
fs::write(
&config_path,
r#"
[global]
force_state = "on"
"#,
)
.unwrap();
let file_path = dir.path().join("test.py");
fs::write(&file_path, "hello\n").unwrap();
cmd()
.args([
file_path.to_str().unwrap(),
"-l",
"1:1",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success();
let result = fs::read_to_string(&file_path).unwrap();
assert!(
result.contains("# hello"),
"config force_state=on should comment the line"
);
}
#[test]
fn test_eol_lf_normalizes_crlf() {
let (_dir, path) = setup_temp_file("line1\r\nline2\r\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--eol", "lf"])
.assert()
.success();
let result = fs::read(&path).unwrap();
assert!(
!result.windows(2).any(|w| w == b"\r\n"),
"should have no CRLF after --eol lf"
);
}
#[test]
fn test_eol_crlf_normalizes_lf() {
let (_dir, path) = setup_temp_file("line1\nline2\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--eol", "crlf"])
.assert()
.success();
let result = fs::read(&path).unwrap();
let content = String::from_utf8(result).unwrap();
assert!(
content.contains("\r\n"),
"should have CRLF after --eol crlf"
);
}
#[test]
fn test_eol_preserve_keeps_original() {
let original = "line1\nline2\n";
let (_dir, path) = setup_temp_file(original, "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--eol", "preserve"])
.assert()
.success();
let result = fs::read(&path).unwrap();
assert!(!result.contains(&b'\r'), "preserve should not introduce CR");
}
#[cfg(unix)]
#[test]
fn test_no_dereference_preserves_symlink() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.py");
fs::write(&target, "hello\nworld\n").unwrap();
let link = dir.path().join("link.py");
symlink(&target, &link).unwrap();
cmd()
.args([link.to_str().unwrap(), "-l", "1:2", "-N"])
.assert()
.success();
assert!(
link.symlink_metadata().unwrap().file_type().is_symlink(),
"symlink should be preserved with -N"
);
let result = fs::read_to_string(&target).unwrap();
assert!(result.contains("#"), "target should be toggled");
}
#[cfg(unix)]
#[test]
fn test_default_replaces_symlink() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.py");
fs::write(&target, "hello\nworld\n").unwrap();
let link = dir.path().join("link.py");
symlink(&target, &link).unwrap();
cmd()
.args([link.to_str().unwrap(), "-l", "1:2"])
.assert()
.success();
assert!(
!link.symlink_metadata().unwrap().file_type().is_symlink(),
"without -N, symlink should be replaced by a regular file"
);
}
#[test]
fn test_eol_invalid_value_errors() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--eol", "foo"])
.assert()
.failure()
.stderr(predicates::str::contains("Invalid --eol value"));
}
#[test]
fn test_encoding_latin1_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.py");
fs::write(&path, [0x63, 0x61, 0x66, 0xe9, 0x0a]).unwrap();
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "-e", "latin-1"])
.assert()
.success();
let bytes = fs::read(&path).unwrap();
assert!(
bytes.windows(2).any(|w| w == [0x23, 0x20]),
"should have # comment marker"
);
assert!(bytes.contains(&0xe9), "should preserve Latin-1 encoding");
}
#[test]
fn test_encoding_default_utf8() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert!(result.contains("#"), "default UTF-8 should work as before");
}
#[test]
fn test_encoding_invalid_errors() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "-e", "bogus-codec"])
.assert()
.failure()
.stderr(predicates::str::contains("Unsupported encoding"));
}
#[test]
fn test_config_global_delimiter_fallback() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join(".toggleConfig");
fs::write(
&config_path,
r#"
[global]
single_line_delimiter = "%%"
"#,
)
.unwrap();
let file_path = dir.path().join("test.py");
fs::write(&file_path, "hello\n").unwrap();
cmd()
.args([
file_path.to_str().unwrap(),
"-l",
"1:1",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success();
let result = fs::read_to_string(&file_path).unwrap();
assert!(
result.contains("%%"),
"global single_line_delimiter should override default when no language-specific config"
);
}
#[test]
fn test_json_output_valid_json() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
let output = cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Value = serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert!(json.is_array(), "JSON output should be an array");
}
#[test]
fn test_json_output_contains_fields() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
let output = cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json.len(), 1);
let entry = &json[0];
assert!(entry["file"].as_str().unwrap().contains("test.py"));
assert_eq!(entry["action"], "toggle_line_range");
assert!(entry["lines_changed"].as_u64().unwrap() > 0);
assert_eq!(entry["success"], true);
assert!(entry.get("error").is_none() || entry["error"].is_null());
assert_eq!(entry["dry_run"], false);
}
#[test]
fn test_json_output_error_case() {
let output = cmd()
.args([
"/tmp/nonexistent_toggle_json_test.py",
"-l",
"1:1",
"--json",
])
.output()
.unwrap();
assert!(!output.status.success());
let json: Vec<Value> =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON even on error");
assert_eq!(json.len(), 1);
assert_eq!(json[0]["success"], false);
assert!(!json[0]["error"].as_str().unwrap().is_empty());
}
#[test]
fn test_json_suppresses_verbose() {
let (_dir, path) = setup_temp_file("hello\n", "test.py");
let output = cmd()
.args([path.to_str().unwrap(), "-l", "1:1", "--json", "-v"])
.output()
.unwrap();
assert!(output.status.success());
assert!(
output.stderr.is_empty(),
"verbose output should be suppressed in JSON mode"
);
let _: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
}
#[test]
fn test_json_with_dry_run() {
let (_dir, path) = setup_temp_file("hello\nworld\n", "test.py");
let output = cmd()
.args([path.to_str().unwrap(), "-l", "1:2", "--json", "--dry-run"])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<Value> = serde_json::from_slice(&output.stdout)
.expect("stdout should be pure JSON with no diff mixed in");
assert_eq!(json[0]["dry_run"], true);
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, "hello\nworld\n");
}
#[test]
fn test_force_uppercase_f_alias_on() {
let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-F", "on", "-l", "1:2"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "# a\n# b\nc\n");
}
#[test]
fn test_force_uppercase_f_alias_off() {
let (_dir, path) = setup_temp_file("# a\n# b\nc\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-F", "off", "-l", "1:2"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "a\nb\nc\n");
}
#[test]
fn test_force_invert_value_comments_then_uncomments() {
let (_dir, path) = setup_temp_file("a\nb\nc\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-f", "invert", "-l", "1:2"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "# a\n# b\nc\n");
cmd()
.args([path.to_str().unwrap(), "--force", "invert", "-l", "1:2"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "a\nb\nc\n");
}
#[test]
fn test_force_invert_with_uppercase_f() {
let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "-F", "invert", "-l", "1:2"])
.assert()
.success();
let result = fs::read_to_string(&path).unwrap();
assert_eq!(result, "# a\n# b\n");
}
#[test]
fn test_force_invalid_value_errors() {
let (_dir, path) = setup_temp_file("a\nb\n", "test.py");
cmd()
.args([path.to_str().unwrap(), "--force", "bogus", "-l", "1:2"])
.assert()
.failure()
.stderr(predicate::str::contains("Invalid --force value 'bogus'"));
}
#[test]
fn test_unknown_extension_with_global_config_delimiter() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("data.xyz");
fs::write(&file_path, "line one\nline two\n").unwrap();
let config_path = dir.path().join(".toggleConfig");
fs::write(&config_path, "[global]\nsingle_line_delimiter = \"//\"\n").unwrap();
cmd()
.args([
file_path.to_str().unwrap(),
"-l",
"1:2",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success();
let result = fs::read_to_string(&file_path).unwrap();
assert_eq!(result, "// line one\n// line two\n");
}
#[test]
fn test_unknown_extension_without_config_errors() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("data.xyz");
fs::write(&file_path, "line one\nline two\n").unwrap();
cmd()
.args([file_path.to_str().unwrap(), "-l", "1:2"])
.assert()
.failure()
.stderr(predicate::str::contains("Unsupported file extension: .xyz"));
}
#[test]
fn test_unknown_extension_error_suggests_alternatives() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("data.xyz");
fs::write(&file_path, "line one\n").unwrap();
cmd()
.args([file_path.to_str().unwrap(), "-l", "1:1"])
.assert()
.failure()
.stderr(predicate::str::contains("--comment-style"))
.stderr(predicate::str::contains("--config"));
}
#[test]
fn test_recursive_toggles_all_files_in_directory() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\nworld\n"), ("b.py", "foo\nbar\n")]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:2", "-R"])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert!(a.contains("# hello"), "a.py should be commented");
assert!(b.contains("# foo"), "b.py should be commented");
}
#[test]
fn test_recursive_traverses_subdirectories() {
let dir = setup_temp_dir_with_files(&[
("top.py", "top\n"),
("sub/nested.py", "nested\n"),
("sub/deep/deep.py", "deep\n"),
]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
.assert()
.success();
assert!(fs::read_to_string(dir.path().join("top.py"))
.unwrap()
.contains("# top"));
assert!(fs::read_to_string(dir.path().join("sub/nested.py"))
.unwrap()
.contains("# nested"));
assert!(fs::read_to_string(dir.path().join("sub/deep/deep.py"))
.unwrap()
.contains("# deep"));
}
#[test]
fn test_recursive_cross_file_section_toggle() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
),
(
"sub/b.py",
"top\n# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\nbottom\n",
),
(
"sub/deep/c.py",
"x\n# toggle:start ID=feat1\nfoo\n# toggle:end ID=feat1\ny\n",
),
]);
cmd()
.args([
dir.path().to_str().unwrap(),
"-R",
"-S",
"feat1",
"-f",
"on",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("sub/b.py")).unwrap();
let c = fs::read_to_string(dir.path().join("sub/deep/c.py")).unwrap();
assert!(a.contains("# hello"), "a.py section should be commented");
assert!(
b.contains("# world"),
"sub/b.py section should be commented"
);
assert!(
c.contains("# foo"),
"sub/deep/c.py section should be commented"
);
}
#[test]
fn test_recursive_skips_unsupported_extensions() {
let dir = setup_temp_dir_with_files(&[
("code.py", "hello\n"),
("readme.md", "# Title\n"),
("data.csv", "a,b,c\n"),
]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
.assert()
.success();
assert!(fs::read_to_string(dir.path().join("code.py"))
.unwrap()
.contains("# hello"));
assert_eq!(
fs::read_to_string(dir.path().join("readme.md")).unwrap(),
"# Title\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("data.csv")).unwrap(),
"a,b,c\n"
);
}
#[test]
fn test_recursive_with_section_toggle() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"before\n# toggle:start ID=feat\nhello\n# toggle:end ID=feat\nafter\n",
),
(
"b.py",
"start\n# toggle:start ID=feat\nworld\n# toggle:end ID=feat\nend\n",
),
]);
cmd()
.args([dir.path().to_str().unwrap(), "-S", "feat", "-f", "on", "-R"])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert!(a.contains("# hello"), "a.py section should be commented");
assert!(b.contains("# world"), "b.py section should be commented");
}
#[test]
fn test_recursive_with_multiple_languages() {
let dir = setup_temp_dir_with_files(&[
("app.py", "print('hi')\n"),
("app.js", "console.log('hi');\n"),
("app.rs", "fn main() {}\n"),
]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1", "-R"])
.assert()
.success();
assert!(fs::read_to_string(dir.path().join("app.py"))
.unwrap()
.contains("# "));
assert!(fs::read_to_string(dir.path().join("app.js"))
.unwrap()
.contains("// "));
assert!(fs::read_to_string(dir.path().join("app.rs"))
.unwrap()
.contains("// "));
}
#[test]
fn test_directory_without_recursive_flag_errors() {
let dir = setup_temp_dir_with_files(&[("test.py", "hello\n")]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1"])
.assert()
.failure()
.stderr(predicates::str::contains("use -R/--recursive"));
}
#[test]
fn test_recursive_with_dry_run() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n"), ("b.py", "world\n")]);
cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1", "-R", "--dry-run"])
.assert()
.success();
assert_eq!(
fs::read_to_string(dir.path().join("a.py")).unwrap(),
"hello\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("b.py")).unwrap(),
"world\n"
);
}
#[test]
fn test_recursive_with_json_output() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n"), ("sub/b.js", "world\n")]);
let output = cmd()
.args([dir.path().to_str().unwrap(), "-l", "1:1", "-R", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
assert!(
json.len() >= 2,
"JSON should have entries for each processed file"
);
assert!(json.iter().all(|e| e["success"] == true));
}
#[test]
fn test_recursive_skips_files_without_matching_section() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
),
("b.py", "no sections here\n"),
(
"c.py",
"# toggle:start ID=other\nstuff\n# toggle:end ID=other\n",
),
]);
cmd()
.args([
dir.path().to_str().unwrap(),
"-R",
"-S",
"feat1",
"-f",
"on",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
assert!(a.contains("# hello"), "a.py should be toggled");
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert_eq!(b, "no sections here\n", "b.py should be unchanged");
let c = fs::read_to_string(dir.path().join("c.py")).unwrap();
assert!(
c.contains("stuff"),
"c.py should be unchanged (different section ID)"
);
}
#[test]
fn test_recursive_force_applies_uniformly() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"before\n# toggle:start ID=feat1\n# already_on\n# toggle:end ID=feat1\nafter\n",
),
(
"b.py",
"top\n# toggle:start ID=feat1\n# also_on\n# toggle:end ID=feat1\nbottom\n",
),
]);
cmd()
.args([
dir.path().to_str().unwrap(),
"-R",
"-S",
"feat1",
"-f",
"off",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert!(
a.contains("already_on") && !a.contains("# already_on"),
"a.py should be uncommented"
);
assert!(
b.contains("also_on") && !b.contains("# also_on"),
"b.py should be uncommented"
);
}
#[test]
fn test_list_sections_discovery() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"# toggle:start ID=feat1 desc=\"Feature one\"\nhello\n# toggle:end ID=feat1\n",
),
(
"b.py",
"# toggle:start ID=feat2\nworld\n# toggle:end ID=feat2\n",
),
(
"sub/c.py",
"# toggle:start ID=feat1\nfoo\n# toggle:end ID=feat1\n",
),
]);
let output = cmd()
.args([dir.path().to_str().unwrap(), "-R", "--list-sections"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("feat1"), "should list feat1");
assert!(stdout.contains("feat2"), "should list feat2");
assert!(stdout.contains("Feature one"), "should show desc for feat1");
}
#[test]
fn test_list_sections_json() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"# toggle:start ID=feat1 desc=\"Feature one\"\nhello\n# toggle:end ID=feat1\n",
),
(
"sub/b.py",
"# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\n",
),
]);
let output = cmd()
.args([
dir.path().to_str().unwrap(),
"-R",
"--list-sections",
"--json",
])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<Value> =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(json.len(), 1, "should have one section ID");
assert_eq!(json[0]["id"], "feat1");
assert_eq!(json[0]["desc"], "Feature one");
let files = json[0]["files"].as_array().unwrap();
assert_eq!(files.len(), 2, "feat1 should appear in 2 files");
assert!(files[0]["file"].as_str().is_some());
assert!(files[0]["start_line"].as_u64().is_some());
assert!(files[0]["end_line"].as_u64().is_some());
}
#[test]
fn test_list_sections_conflicts_with_line_flag() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.args([
dir.path().join("a.py").to_str().unwrap(),
"--list-sections",
"-l",
"1:1",
])
.assert()
.failure()
.stderr(predicates::str::contains(
"--list-sections cannot be combined with --line",
));
}
#[test]
fn test_list_sections_conflicts_with_force_flag() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.args([
dir.path().join("a.py").to_str().unwrap(),
"--list-sections",
"-f",
"on",
])
.assert()
.failure()
.stderr(predicates::str::contains(
"--list-sections cannot be combined with --force",
));
}
#[test]
fn test_cross_file_json_output() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"# toggle:start ID=feat1 desc=\"My feature\"\nhello\n# toggle:end ID=feat1\n",
),
(
"sub/b.py",
"# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\n",
),
]);
let output = cmd()
.args([
dir.path().to_str().unwrap(),
"-R",
"-S",
"feat1",
"-f",
"on",
"--json",
])
.output()
.unwrap();
assert!(output.status.success());
let json: Vec<Value> =
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
assert_eq!(json.len(), 2, "should have per-file entries");
for entry in &json {
assert_eq!(entry["action"], "toggle_section");
assert_eq!(entry["success"], true);
assert_eq!(entry["section_id"], "feat1");
assert!(entry["lines_changed"].as_u64().unwrap() > 0);
}
let has_desc = json.iter().any(|e| e["desc"] == "My feature");
assert!(has_desc, "at least one entry should have desc");
}
#[test]
fn test_section_desc_in_verbose_output() {
let content = "# toggle:start ID=feat1 desc=\"Debug logging\"\nhello\n# toggle:end ID=feat1\n";
let (_dir, path) = setup_temp_file(content, "test.py");
let output = cmd()
.args([path.to_str().unwrap(), "-S", "feat1", "-f", "on", "-v"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("Section desc: Debug logging"),
"verbose should show desc"
);
}
#[test]
fn test_scan_discovers_sections() {
let dir = TempDir::new().unwrap();
let content = "\
# toggle:start ID=debug desc=\"Debug output\"
# print('debug')
# toggle:end ID=debug
# toggle:start ID=feature
print('feature')
# toggle:end ID=feature
";
fs::write(dir.path().join("app.py"), content).unwrap();
let output = cmd()
.args([dir.path().to_str().unwrap(), "--scan"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("debug"), "should list debug section");
assert!(stdout.contains("feature"), "should list feature section");
assert!(stdout.contains("commented"), "should show commented state");
assert!(
stdout.contains("uncommented"),
"should show uncommented state"
);
}
#[test]
fn test_scan_json_output() {
let dir = TempDir::new().unwrap();
let content = "# toggle:start ID=test desc=\"A test\"\n# code\n# toggle:end ID=test\n";
fs::write(dir.path().join("file.py"), content).unwrap();
let output = cmd()
.args([dir.path().to_str().unwrap(), "--scan", "--json"])
.output()
.unwrap();
assert!(output.status.success());
let json: Value = serde_json::from_slice(&output.stdout)
.expect("scan --json should produce valid nested JSON");
let sections = json["sections"].as_array().expect("sections array");
assert_eq!(sections.len(), 1);
assert_eq!(sections[0]["id"], "test");
assert_eq!(sections[0]["type"], "solo");
let files = sections[0]["files"].as_array().expect("files array");
assert_eq!(files.len(), 1);
assert_eq!(files[0]["state"], "commented");
assert_eq!(files[0]["desc"], "A test");
assert!(files[0]["start"].is_number());
assert!(files[0]["end"].is_number());
}
#[test]
fn test_scan_empty_directory() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("empty.py"), "print('no sections')\n").unwrap();
let output = cmd()
.args([dir.path().to_str().unwrap(), "--scan"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("No toggle sections found"));
}
#[test]
fn test_scan_with_line_flag_errors() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.py"), "x\n").unwrap();
cmd()
.args([dir.path().to_str().unwrap(), "--scan", "-l", "1:2"])
.assert()
.failure()
.stderr(predicate::str::contains("--scan cannot be combined"));
}
#[test]
fn test_scan_with_section_flag_succeeds_for_unknown_group() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.py"), "x\n").unwrap();
cmd()
.args([dir.path().to_str().unwrap(), "--scan", "-S", "foo"])
.assert()
.success()
.stdout(predicate::str::contains("No sections found for 'foo'"));
}
#[test]
fn test_scan_does_not_modify_files() {
let dir = TempDir::new().unwrap();
let content = "# toggle:start ID=sec\ncode\n# toggle:end ID=sec\n";
let file = dir.path().join("test.py");
fs::write(&file, content).unwrap();
cmd()
.args([dir.path().to_str().unwrap(), "--scan"])
.assert()
.success();
let after = fs::read_to_string(&file).unwrap();
assert_eq!(after, content, "scan should not modify files");
}
#[test]
fn test_scan_multiple_directories() {
let dir = TempDir::new().unwrap();
let pkg_a = dir.path().join("pkg_a");
let pkg_b = dir.path().join("pkg_b");
fs::create_dir(&pkg_a).unwrap();
fs::create_dir(&pkg_b).unwrap();
fs::write(
pkg_a.join("mod.py"),
"# toggle:start ID=alpha\n# x\n# toggle:end ID=alpha\n",
)
.unwrap();
fs::write(
pkg_b.join("mod.rs"),
"// toggle:start ID=beta\n// y\n// toggle:end ID=beta\n",
)
.unwrap();
let output = cmd()
.args([pkg_a.to_str().unwrap(), pkg_b.to_str().unwrap(), "--scan"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("alpha"), "should find alpha section");
assert!(stdout.contains("beta"), "should find beta section");
}
#[test]
fn test_atomic_happy_path_multi_file() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\nworld\n"), ("b.py", "foo\nbar\n")]);
cmd()
.current_dir(dir.path())
.args([dir.path().to_str().unwrap(), "-R", "-l", "1:1", "--atomic"])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert_eq!(a, "# hello\nworld\n", "a.py should be toggled");
assert_eq!(b, "# foo\nbar\n", "b.py should be toggled");
assert!(
!dir.path().join(".toggle-atomic.journal").exists(),
"journal should be cleaned up"
);
assert!(
!dir.path().join(".toggle-atomic.lock").exists(),
"lock should be cleaned up"
);
}
#[test]
fn test_atomic_no_changes_does_nothing() {
let dir = setup_temp_dir_with_files(&[("a.py", "# hello\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"-f",
"on",
"--atomic",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
assert_eq!(a, "# hello\n");
}
#[test]
fn test_atomic_implies_backup_by_default() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--atomic",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
assert_eq!(a, "# hello\n");
assert!(!dir.path().join("a.py.toggle-atomic-backup").exists());
}
#[test]
fn test_atomic_no_backup_flag() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
let output = cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--atomic",
"--no-backup",
])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("without backups"),
"should warn about no backups"
);
}
#[test]
fn test_atomic_with_dry_run_errors() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--atomic",
"--dry-run",
])
.assert()
.failure()
.stderr(predicates::str::contains(
"--atomic cannot be combined with --dry-run",
));
}
#[test]
fn test_no_backup_without_atomic_errors() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--no-backup",
])
.assert()
.failure()
.stderr(predicates::str::contains(
"--no-backup is only valid with --atomic",
));
}
#[test]
fn test_recover_forward_without_recover_errors() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--recover-forward",
])
.assert()
.failure()
.stderr(predicates::str::contains(
"--recover-forward requires --recover",
));
}
#[test]
fn test_atomic_section_toggle_multi_file() {
let dir = setup_temp_dir_with_files(&[
(
"a.py",
"before\n# toggle:start ID=feat1\nhello\n# toggle:end ID=feat1\nafter\n",
),
(
"b.py",
"top\n# toggle:start ID=feat1\nworld\n# toggle:end ID=feat1\nbottom\n",
),
]);
cmd()
.current_dir(dir.path())
.args([
dir.path().to_str().unwrap(),
"-R",
"-S",
"feat1",
"-f",
"on",
"--atomic",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
let b = fs::read_to_string(dir.path().join("b.py")).unwrap();
assert!(a.contains("# hello"), "a.py should be commented");
assert!(b.contains("# world"), "b.py should be commented");
}
#[test]
fn test_recover_no_journal_is_noop() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
let output = cmd()
.current_dir(dir.path())
.args([dir.path().join("a.py").to_str().unwrap(), "--recover"])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("No journal found"));
}
#[test]
fn test_recover_from_staged_journal() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.py");
let temp = dir.path().join("temp_staged_file");
fs::write(&target, "original\n").unwrap();
fs::write(&temp, "modified\n").unwrap();
let journal = toggle::journal::Journal::new(
vec![toggle::journal::JournalEntry {
target_path: target.clone(),
temp_path: temp.clone(),
backup_path: None,
content_sha256: "xxx".to_string(),
rename_completed: false,
}],
false,
);
let journal_path = dir.path().join(".toggle-atomic.journal");
toggle::journal::persist_journal(&journal, &journal_path).unwrap();
cmd()
.current_dir(dir.path())
.args([target.to_str().unwrap(), "--recover"])
.assert()
.success();
assert!(!temp.exists(), "temp file should be cleaned up");
assert!(!journal_path.exists(), "journal should be deleted");
assert_eq!(
fs::read_to_string(&target).unwrap(),
"original\n",
"original should be untouched"
);
}
#[test]
fn test_journal_blocks_new_operations() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.py");
fs::write(&target, "hello\n").unwrap();
let journal_path = dir.path().join(".toggle-atomic.journal");
fs::write(&journal_path, "{}").unwrap();
cmd()
.current_dir(dir.path())
.args([target.to_str().unwrap(), "-l", "1:1"])
.assert()
.failure()
.stderr(predicates::str::contains("previous atomic operation"));
}
#[test]
fn test_atomic_with_verbose() {
let dir = setup_temp_dir_with_files(&[("a.py", "hello\n")]);
let output = cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"1:1",
"--atomic",
"-v",
])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Staging"));
assert!(stderr.contains("Committing"));
assert!(stderr.contains("successful"));
}
#[test]
fn test_atomic_single_file() {
let dir = setup_temp_dir_with_files(&[("a.py", "x\ny\nz\n")]);
cmd()
.current_dir(dir.path())
.args([
dir.path().join("a.py").to_str().unwrap(),
"-l",
"2:2",
"--atomic",
])
.assert()
.success();
let a = fs::read_to_string(dir.path().join("a.py")).unwrap();
assert_eq!(a, "x\n# y\nz\n");
}
fn copy_variants_fixture() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let dst = dir.path().join("variants.py");
fs::copy("tests/fixtures/variants.py", &dst).unwrap();
(dir, dst)
}
#[test]
fn variant_pair_flip() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "db"])
.assert()
.success();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# import sqlite3"), "after: {content}");
assert!(content.contains("\nimport psycopg2"), "after: {content}");
}
#[test]
fn variant_activate_named() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "db:postgres"])
.assert()
.success();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("\nimport psycopg2"), "after: {content}");
assert!(content.contains("# import sqlite3"), "after: {content}");
}
#[test]
fn variant_three_errors_without_qualifier() {
let (_dir, path) = copy_variants_fixture();
let before = fs::read_to_string(&path).unwrap();
cmd()
.args([path.to_str().unwrap(), "-S", "cache"])
.assert()
.failure()
.stderr(predicate::str::contains("cache"))
.stderr(predicate::str::contains("3 variants"));
let after = fs::read_to_string(&path).unwrap();
assert_eq!(before, after, "file modified despite error");
}
#[test]
fn variant_force_on_all_in_group() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "cache", "--force", "on"])
.assert()
.success();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# import redis"), "after: {content}");
assert!(!content.contains("# # import memcache"), "after: {content}");
}
#[test]
fn variant_force_off_all_in_group() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "cache", "--force", "off"])
.assert()
.success();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("\nimport memcache"), "after: {content}");
assert!(content.contains("\ncache = {}"), "after: {content}");
}
#[test]
fn variant_solo_unchanged_behavior() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "debug"])
.assert()
.success();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("# print(\"debug enabled\")"),
"after: {content}"
);
}
#[test]
fn pair_succeeds_on_two_variant_group() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "db", "--pair"])
.assert()
.success();
}
#[test]
fn pair_errors_on_three_variant_group() {
let (_dir, path) = copy_variants_fixture();
let before = fs::read_to_string(&path).unwrap();
cmd()
.args([path.to_str().unwrap(), "-S", "cache", "--pair"])
.assert()
.failure()
.stderr(predicate::str::contains("--pair"))
.stderr(predicate::str::contains("3"));
let after = fs::read_to_string(&path).unwrap();
assert_eq!(before, after, "file modified despite --pair failure");
}
#[test]
fn pair_errors_on_one_variant_group() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "-S", "debug", "--pair"])
.assert()
.failure()
.stderr(predicate::str::contains("--pair"));
}
#[test]
fn pair_without_section_errors() {
let (_dir, path) = copy_variants_fixture();
cmd()
.args([path.to_str().unwrap(), "--pair"])
.assert()
.failure();
}
#[test]
fn scan_table_shows_type_column() {
let output = cmd()
.args(["--scan", "tests/fixtures/variants.py"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("SECTION"), "stdout: {stdout}");
assert!(stdout.contains("TYPE"), "stdout: {stdout}");
assert!(stdout.contains("db:sqlite"), "stdout: {stdout}");
assert!(stdout.contains("pair"), "stdout: {stdout}");
assert!(stdout.contains("group"), "stdout: {stdout}");
assert!(stdout.contains("solo"), "stdout: {stdout}");
}
#[test]
fn scan_recursive_emits_summary() {
let dir = TempDir::new().unwrap();
fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();
let output = cmd()
.args(["--scan", "-R", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("FILES"), "stdout: {stdout}");
assert!(stdout.contains("VARIANTS"), "stdout: {stdout}");
assert!(stdout.contains("db"), "stdout: {stdout}");
assert!(stdout.contains("cache"), "stdout: {stdout}");
assert!(!stdout.contains("variants.py"), "stdout: {stdout}");
}
#[test]
fn scan_detailed_view_for_group() {
let dir = TempDir::new().unwrap();
fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();
let output = cmd()
.args(["--scan", "-S", "cache", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("GROUP: cache"), "stdout: {stdout}");
assert!(stdout.contains("group, 3 variants"), "stdout: {stdout}");
assert!(stdout.contains("cache:redis"), "stdout: {stdout}");
assert!(stdout.contains("cache:memcached"), "stdout: {stdout}");
assert!(stdout.contains("cache:inmemory"), "stdout: {stdout}");
assert!(stdout.contains("variants.py"), "stdout: {stdout}");
}
#[test]
fn scan_json_emits_nested_tree() {
let dir = TempDir::new().unwrap();
fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();
let output = cmd()
.args(["--scan", "--json", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let sections = v["sections"].as_array().expect("sections array");
let debug = sections
.iter()
.find(|e| e["id"] == "debug")
.expect("debug entry");
assert_eq!(debug["type"], "solo");
assert!(debug["files"].as_array().unwrap().len() >= 1);
let db = sections
.iter()
.find(|e| e["group"] == "db")
.expect("db entry");
assert_eq!(db["type"], "pair");
let variants = db["variants"].as_array().unwrap();
assert_eq!(variants.len(), 2);
let cache = sections
.iter()
.find(|e| e["group"] == "cache")
.expect("cache entry");
assert_eq!(cache["type"], "group");
assert_eq!(cache["variants"].as_array().unwrap().len(), 3);
}
#[test]
fn scan_detailed_view_for_specific_variant() {
let dir = TempDir::new().unwrap();
fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();
let output = cmd()
.args(["--scan", "-S", "db:postgres", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("db:postgres"), "stdout: {stdout}");
assert!(!stdout.contains("db:sqlite"), "stdout: {stdout}");
}
#[test]
fn check_reports_pair_mismatch_with_pair_flag() {
let dir = TempDir::new().unwrap();
fs::copy("tests/fixtures/variants.py", dir.path().join("variants.py")).unwrap();
let output = cmd()
.args(["--scan", "--check", "--pair", dir.path().to_str().unwrap()])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cache"), "stdout: {stdout}");
assert!(
stdout.contains("WARN") || stdout.contains("ERR"),
"stdout: {stdout}"
);
}
#[test]
fn check_clean_scan_reports_ok() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("clean.py"),
"# toggle:start ID=foo\nx = 1\n# toggle:end ID=foo\n",
)
.unwrap();
let output = cmd()
.args(["--scan", "--check", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("OK"), "stdout: {stdout}");
}
#[test]
fn check_without_scan_errors() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.py"), "x\n").unwrap();
cmd()
.args(["--check", dir.path().to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("--check requires --scan"));
}
#[test]
fn check_unclosed_marker_exits_nonzero() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("bad.py"), "# toggle:start ID=foo\nx = 1\n").unwrap();
let output = cmd()
.args(["--scan", "--check", dir.path().to_str().unwrap()])
.output()
.unwrap();
assert!(!output.status.success(), "expected failure");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("unclosed"), "stdout: {stdout}");
}
#[test]
fn completions_bash_emits_completion_script() {
let output = cmd().args(["--completions", "bash"]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("_toggle()"), "stdout: {stdout}");
assert!(stdout.contains("--scan"), "expected flag in completions");
}
#[test]
fn man_page_emits_roff() {
let output = cmd().args(["--man"]).output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(".TH toggle"),
"stdout head: {}",
&stdout[..stdout.len().min(200)]
);
assert!(stdout.contains(".SH NAME"), "expected NAME section");
}
#[test]
fn no_paths_errors() {
cmd()
.assert()
.failure()
.stderr(predicate::str::contains("path is required"));
}