mod help_text;
use std::fs;
use std::io::Read;
use moesniper::{
auto_indent_content, check_file_size, compute_context_hash, count_recent_backups,
create_backup, find_latest_backup, generate_preview, handle_backtrack_error, hex_decode,
needs_indent_fix, normalize_path, purge_old_backups, recommend_from_risk, validate_indentation,
verify_context, write_atomic_with_dal, ManifestOp, RiskTelemetry, SniperConfig, SniperLock,
};
use llmosafe::ResourceGuard;
fn run(args: Vec<String>) -> std::process::ExitCode {
use std::process::ExitCode;
if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
eprint!("{}", help_text::HELP);
return ExitCode::SUCCESS;
}
if args[0] == "-v" || args[0] == "--version" {
println!("{} {}", moesniper::NAME, moesniper::VERSION);
return ExitCode::SUCCESS;
}
let dry_run = args.iter().any(|a| a == "--dry-run");
let json_out = args.iter().any(|a| a == "--json");
let use_stdin = args.iter().any(|a| a == "--stdin");
let auto_indent = args.iter().any(|a| a == "--auto-indent");
let force_indent = args.iter().any(|a| a == "--force-indent");
let mut context_hash: Option<String> = None;
let mut ctx_pos: Option<usize> = None;
if let Some(pos) = args.iter().position(|a| a == "--context") {
ctx_pos = Some(pos);
if pos + 1 < args.len() {
context_hash = Some(args[pos + 1].clone());
}
}
let args: Vec<&str> = args
.iter()
.enumerate()
.filter(|(i, a)| {
if let Some(pos) = ctx_pos {
if *i == pos || *i == pos + 1 {
return false;
}
}
!(*a == "--dry-run"
|| *a == "--json"
|| *a == "--stdin"
|| *a == "--auto-indent"
|| *a == "--force-indent")
})
.map(|(_, s)| s.as_str())
.collect();
let result = match args.as_slice() {
["encode"] if use_stdin => {
let mut buffer = String::new();
match std::io::stdin().read_to_string(&mut buffer) {
Ok(_) => cmd_encode(&buffer),
Err(e) => err(format!("read stdin: {e}")),
}
}
["encode", "--file", path] => match fs::read_to_string(path) {
Ok(content) => cmd_encode(&content),
Err(e) => err(format!("read {path}: {e}")),
},
["encode", text] => cmd_encode(text),
["context", file, start, end] => match (parse_line(start), parse_line(end)) {
(Ok(s), Ok(e)) => cmd_context(file, s, e),
(Err(err_msg), _) | (_, Err(err_msg)) => err(err_msg),
},
[file, "--undo"] => cmd_undo(file),
[file, "--manifest"] if use_stdin => cmd_manifest(
file,
None,
dry_run,
auto_indent,
force_indent,
context_hash.as_deref(),
),
[file, "--manifest", manifest] => cmd_manifest(
file,
Some(manifest),
dry_run,
auto_indent,
force_indent,
context_hash.as_deref(),
),
[file, start, end, "--delete"] => {
if use_stdin {
err("cannot use --stdin with --delete".into())
} else {
match (parse_line(start), parse_line(end)) {
(Ok(s), Ok(e)) => cmd_splice(
file,
s,
e,
"",
dry_run,
auto_indent,
force_indent,
context_hash.as_deref(),
),
(Err(e), _) | (_, Err(e)) => err(e),
}
}
}
[file, start, end] if use_stdin => {
let mut buffer = String::new();
match std::io::stdin().read_to_string(&mut buffer) {
Ok(_) => match (parse_line(start), parse_line(end)) {
(Ok(ln_start), Ok(ln_end)) => cmd_splice(
file,
ln_start,
ln_end,
&buffer,
dry_run,
auto_indent,
force_indent,
context_hash.as_deref(),
),
(Err(e), _) | (_, Err(e)) => err(e),
},
Err(e) => err(format!("read stdin: {e}")),
}
}
[file, start, end, hex] => match (parse_line(start), parse_line(end)) {
(Ok(s), Ok(e)) => match hex_decode(hex) {
Ok(content) => cmd_splice(
file,
s,
e,
&content,
dry_run,
auto_indent,
force_indent,
context_hash.as_deref(),
),
Err(msg) => err(format!("hex decode: {msg}")),
},
(Err(e), _) | (_, Err(e)) => err(e),
},
_ => {
eprintln!("error: bad arguments. Run 'sniper --help'");
return ExitCode::FAILURE;
}
};
if json_out {
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
} else {
match result.status.as_str() {
"ok" => {
if let Some(msg) = &result.message {
println!("{}", msg);
} else {
println!(
"ok: {} -{} +{}",
result.file.as_deref().unwrap_or("?"),
result.lines_removed,
result.lines_inserted
);
}
}
"restored" => println!("restored: {}", result.backup.as_deref().unwrap_or("?")),
"encoded" => println!("{}", result.message.as_deref().unwrap_or("")),
"dry_run" => {
println!("=== DRY RUN PREVIEW ===");
println!("File: {}", result.file.as_deref().unwrap_or("?"));
println!("Lines to remove: {}", result.lines_removed);
println!("Lines to insert: {}", result.lines_inserted);
if let Some(ref warning) = result.indent_warning {
println!("\n⚠️ INDENTATION WARNING:");
for line in warning.lines() {
println!(" {}", line);
}
}
if result.indent_fixed == Some(true) {
println!("\n✓ Auto-indent applied");
}
if let Some(ref preview) = result.diff_preview {
println!("\n--- Diff Preview ---");
for line in preview {
println!("{}", line);
}
}
if result.ai_hint.is_some() {
println!("\nHint: {}", result.ai_hint.as_deref().unwrap_or(""));
}
if json_out {
println!("\n--- JSON Output ---");
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
}
}
_ => {
eprintln!("error: {}", result.message.as_deref().unwrap_or("unknown"));
return ExitCode::FAILURE;
}
}
}
ExitCode::SUCCESS
}
fn main() -> std::process::ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
run(args)
}
#[derive(serde::Serialize, Default)]
struct CliResult {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
lines_removed: usize,
lines_inserted: usize,
#[serde(skip_serializing_if = "Option::is_none")]
total_lines: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
operations: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
backup: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ai_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
diff_preview: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
indent_warning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
indent_fixed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
line_shift: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
risk: Option<RiskTelemetry>,
#[serde(skip_serializing_if = "Option::is_none")]
recommended_action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
manifest_ops: Option<Vec<ManifestOpDiff>>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct ManifestOpDiff {
start: usize,
end: usize,
diff_preview: Vec<String>,
}
fn cmd_encode(text: &str) -> CliResult {
let hex = moesniper::hex_encode(text.as_bytes());
CliResult {
status: "encoded".into(),
message: Some(hex),
..Default::default()
}
}
fn cmd_context(filepath: &str, start: usize, end: usize) -> CliResult {
let config = SniperConfig::from_env();
if let Err(e) = normalize_path(filepath) {
return err(e);
}
if let Err(e) = check_file_size(filepath, config.max_file_size) {
return err(e);
}
let text = match fs::read_to_string(filepath) {
Ok(t) => t,
Err(e) => return err(format!("read file: {e}")),
};
let lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect();
if start < 1 || end > lines.len() || start > end + 1 {
if start == lines.len() + 1 && (start == end + 1 || start == end) {
} else {
return err(format!(
"line range {start}-{end} out of bounds (file has {} lines)",
lines.len()
));
}
}
let full_hash = compute_context_hash(&lines, start, end);
let short_hash = full_hash[..16].to_string();
CliResult {
status: "ok".into(),
message: Some(short_hash),
..Default::default()
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_splice(
filepath: &str,
start: usize,
end: usize,
content: &str,
dry_run: bool,
auto_indent: bool,
force_indent: bool,
context_hash: Option<&str>,
) -> CliResult {
let config = SniperConfig::from_env();
if let Err(e) = normalize_path(filepath) {
return err(e);
}
if let Err(e) = check_file_size(filepath, config.max_file_size) {
return err(e);
}
let _lock: Option<SniperLock> = if !dry_run {
match SniperLock::acquire_with_config(filepath, &config) {
Ok(l) => Some(l),
Err(e) => return err(e),
}
} else {
None
};
let text = match fs::read_to_string(filepath) {
Ok(t) => t,
Err(e) => return err(handle_backtrack_error(e, "Read")),
};
let lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect();
if let Some(expected) = context_hash {
if let Err(e) = verify_context(&lines, start, end, expected) {
return err(e);
}
}
if start < 1 || end > lines.len() || start > end + 1 {
if start == lines.len() + 1 && (start == end + 1 || start == end) {
} else {
return err(format!(
"line range {start}-{end} out of bounds (file has {} lines)",
lines.len()
));
}
}
let s = start - 1;
let removed_lines_count = if s < lines.len() {
let actual_end = end.min(lines.len());
actual_end - s
} else {
0
};
let mut new_lines: Vec<String> = if content.is_empty() {
vec![]
} else {
content.split_inclusive('\n').map(String::from).collect()
};
let is_delete = content.is_empty();
let mut indent_fixed = None;
let mut indent_warning = None;
if !is_delete {
if auto_indent && needs_indent_fix(&lines, start, end, content) {
let fixed = auto_indent_content(&lines, start, end, content);
new_lines = fixed.split_inclusive('\n').map(String::from).collect();
indent_fixed = Some(true);
}
if !force_indent {
let (valid, warning, _suggested) = validate_indentation(&lines, start, end, &new_lines);
if !valid {
indent_warning = warning.clone();
if !dry_run {
let msg = warning.unwrap_or_else(|| "Unknown indentation error".to_string());
return CliResult {
status: "error".into(),
file: Some(filepath.into()),
message: Some(format!("Indentation validation failed: {}", msg)),
indent_warning,
..Default::default()
};
}
}
}
}
let diff_preview = if dry_run && !is_delete {
Some(generate_preview(&lines, &new_lines, start, end))
} else {
None
};
let guard = ResourceGuard::auto(0.5);
let risk = RiskTelemetry::from_guard(&guard);
if dry_run {
let ai_hint = Some(if is_delete {
format!("verify: {} around line {}", filepath, start)
} else {
format!("verify: read {} lines {}-{}", filepath, start, end)
});
return CliResult {
status: "dry_run".into(),
file: Some(filepath.into()),
lines_removed: removed_lines_count,
lines_inserted: new_lines.len(),
ai_hint,
diff_preview,
indent_warning,
indent_fixed,
line_shift: Some(new_lines.len() as i64 - removed_lines_count as i64),
risk: None,
recommended_action: None,
..Default::default()
};
}
let bk = match create_backup(filepath) {
Ok(b) => b,
Err(e) => return err(e),
};
let new_lines_count = new_lines.len();
let mut modified_lines = lines.clone();
if s < modified_lines.len() {
let actual_end = end.min(modified_lines.len());
modified_lines.splice(s..actual_end, new_lines);
} else {
modified_lines.extend(new_lines);
}
let lines_refs: Vec<&str> = modified_lines.iter().map(|s| s.as_str()).collect();
if let Err(e) = write_atomic_with_dal(filepath, &lines_refs, &guard, config.dal_level) {
return err(e);
}
if let Err(e) = purge_old_backups(filepath, &config) {
eprintln!("[SNIPER] Backup purge warning: {e}");
}
let manifest_promotion = count_recent_backups(filepath, config.lock_timeout.as_secs())
.map(|count| count >= 3)
.unwrap_or(false);
let ai_hint = Some(if manifest_promotion {
"Multiple edits to this file. Consider batching with manifest.".into()
} else if is_delete {
format!("verify: {} around line {}", filepath, start)
} else {
format!("verify: read {} lines {}-{}", filepath, start, end)
});
let recommended_action = Some(recommend_from_risk(&risk));
CliResult {
status: "ok".into(),
file: Some(filepath.into()),
lines_removed: removed_lines_count,
lines_inserted: new_lines_count,
total_lines: Some(modified_lines.len()),
backup: Some(bk),
ai_hint,
indent_warning,
indent_fixed,
line_shift: Some(new_lines_count as i64 - removed_lines_count as i64),
risk: Some(risk),
recommended_action,
..Default::default()
}
}
fn cmd_manifest(
filepath: &str,
manifest_path: Option<&str>,
dry_run: bool,
auto_indent: bool,
force_indent: bool,
context_hash: Option<&str>,
) -> CliResult {
let manifest = match manifest_path {
Some(path) => match fs::read_to_string(path) {
Ok(m) => m,
Err(e) => return err(format!("read manifest: {e}")),
},
None => {
let mut buffer = String::new();
match std::io::stdin().read_to_string(&mut buffer) {
Ok(_) => buffer,
Err(e) => return err(format!("read manifest from stdin: {e}")),
}
}
};
cmd_manifest_impl(
filepath,
&manifest,
dry_run,
auto_indent,
force_indent,
context_hash,
)
}
fn cmd_manifest_impl(
filepath: &str,
manifest: &str,
dry_run: bool,
auto_indent: bool,
force_indent: bool,
context_hash: Option<&str>,
) -> CliResult {
let config = SniperConfig::from_env();
if let Err(e) = normalize_path(filepath) {
return err(e);
}
if let Err(e) = check_file_size(filepath, config.max_file_size) {
return err(e);
}
let _lock: Option<SniperLock> = if !dry_run {
match SniperLock::acquire_with_config(filepath, &config) {
Ok(l) => Some(l),
Err(e) => return err(e),
}
} else {
None
};
let mut ops: Vec<ManifestOp> = match serde_json::from_str(manifest) {
Ok(o) => o,
Err(e) => return err(format!("parse manifest: {e}")),
};
let text = match fs::read_to_string(filepath) {
Ok(t) => t,
Err(e) => return err(handle_backtrack_error(e, "Read")),
};
let mut lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect();
ops.sort_by_key(|b| std::cmp::Reverse(b.start));
for i in 1..ops.len() {
if ops[i].start == ops[i - 1].start {
return err(format!(
"overlapping manifest operations at line {}",
ops[i].start
));
}
}
let bk = if !dry_run {
match create_backup(filepath) {
Ok(b) => Some(b),
Err(e) => return err(e),
}
} else {
None
};
if let Some(expected) = context_hash {
if let Some(first_op) = ops.first() {
let first_end = first_op.end.unwrap_or(first_op.start);
if let Err(e) = verify_context(&lines, first_op.start, first_end, expected) {
return err(e);
}
}
}
let mut total_removed = 0usize;
let mut total_inserted = 0usize;
let mut manifest_ops: Vec<ManifestOpDiff> = Vec::new();
for op in &ops {
let start = op.start;
let end = op.end.unwrap_or(op.start);
if start < 1 || end > lines.len() || start > end + 1 {
if start == lines.len() + 1 && (start == end + 1 || start == end) {
} else {
return err(format!(
"line range {start}-{end} out of bounds (file has {} lines)",
lines.len()
));
}
}
let s = start - 1;
let actual_e = end.min(lines.len());
if op.delete.unwrap_or(false) {
if op.hex.is_some() {
return err("Cannot both delete and insert in the same manifest operation".into());
}
total_removed += lines.splice(s..actual_e, std::iter::empty()).count();
if dry_run {
let new_empty: Vec<String> = Vec::new();
let diff_preview = generate_preview(&lines, &new_empty, op.start, actual_e);
manifest_ops.push(ManifestOpDiff {
start: op.start,
end: actual_e,
diff_preview,
});
}
} else if let Some(ref hex) = op.hex {
let content = match hex_decode(hex) {
Ok(c) => c,
Err(e) => return err(format!("hex decode: {e}")),
};
let final_content =
if auto_indent && needs_indent_fix(&lines, op.start, actual_e, &content) {
auto_indent_content(&lines, op.start, actual_e, &content)
} else {
content
};
if !force_indent {
let new_lines_for_check: Vec<String> = final_content
.split_inclusive('\n')
.map(String::from)
.collect();
let (valid, warning, _) =
validate_indentation(&lines, op.start, actual_e, &new_lines_for_check);
if !valid && !dry_run {
return CliResult {
status: "error".into(),
file: Some(filepath.into()),
message: Some(format!(
"Indentation validation failed at line {}: {}",
op.start,
warning.as_deref().unwrap_or_default()
)),
indent_warning: warning,
..Default::default()
};
}
}
let new: Vec<String> = final_content
.split_inclusive('\n')
.map(String::from)
.collect();
if dry_run {
let diff_preview = generate_preview(&lines, &new, op.start, actual_e);
manifest_ops.push(ManifestOpDiff {
start: op.start,
end: actual_e,
diff_preview,
});
}
total_removed += actual_e - s;
total_inserted += new.len();
if s < lines.len() {
lines.splice(s..actual_e, new);
} else {
lines.extend(new);
}
} else {
return err(format!(
"manifest operation at line {start} must specify either 'delete' or 'hex'"
));
}
}
let lines_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let guard = ResourceGuard::auto(0.5);
let risk = RiskTelemetry::from_guard(&guard);
if !dry_run {
if let Err(e) =
write_atomic_with_dal(filepath, &lines_refs, &guard, config.dal_level)
{
return err(e);
}
if let Err(e) = purge_old_backups(filepath, &config) {
eprintln!("[SNIPER] Backup purge warning: {e}");
}
let ai_hint = Some(format!(
"verify: read {} around line {}",
filepath,
ops.first().map(|o| o.start).unwrap_or(1)
));
let recommended_action = Some(recommend_from_risk(&risk));
return CliResult {
status: "ok".into(),
file: Some(filepath.into()),
lines_removed: total_removed,
lines_inserted: total_inserted,
total_lines: Some(lines.len()),
operations: Some(ops.len()),
ai_hint,
backup: bk,
line_shift: Some(total_inserted as i64 - total_removed as i64),
risk: Some(risk),
recommended_action,
..Default::default()
};
}
let ai_hint = Some(format!(
"verify: read {} around line {}",
filepath,
ops.first().map(|o| o.start).unwrap_or(1)
));
let recommended_action = if dry_run {
None
} else {
Some(recommend_from_risk(&risk))
};
CliResult {
status: if dry_run { "dry_run" } else { "ok" }.into(),
file: Some(filepath.into()),
lines_removed: total_removed,
lines_inserted: total_inserted,
total_lines: Some(lines.len()),
operations: Some(ops.len()),
ai_hint,
backup: bk,
line_shift: Some(total_inserted as i64 - total_removed as i64),
risk: if dry_run { None } else { Some(risk) },
recommended_action,
manifest_ops: if dry_run { Some(manifest_ops) } else { None },
..Default::default()
}
}
fn cmd_undo(filepath: &str) -> CliResult {
let config = SniperConfig::from_env();
let _lock = match SniperLock::acquire_with_config(filepath, &config) {
Ok(l) => l,
Err(e) => return err(e),
};
let latest = match find_latest_backup(filepath) {
Ok(Some(l)) => l,
Ok(None) => return err(format!("no backup for {filepath}")),
Err(e) => return err(e),
};
let tmp = format!("{}.sniper_undo_tmp", filepath);
if let Err(e) = fs::copy(&latest, &tmp) {
let _ = fs::remove_file(&tmp);
return err(format!("restore (copy to temp): {e}"));
}
if let Err(e) = fs::rename(&tmp, filepath) {
let _ = fs::remove_file(&tmp);
return err(format!("restore (rename): {e}"));
}
let _ = fs::remove_file(&latest);
let ai_hint = Some(format!("verify restore: read {}", filepath));
CliResult {
status: "restored".into(),
backup: Some(latest.to_string_lossy().into()),
ai_hint,
..Default::default()
}
}
fn parse_line(s: &str) -> Result<usize, String> {
s.parse().map_err(|_| format!("invalid line number: {s}"))
}
fn err(msg: String) -> CliResult {
let ai_hint = if msg.contains("no such file") || msg.contains("not found") {
Some("check path exists before editing".into())
} else if msg.contains("out of bounds") || msg.contains("exceeds file length") {
Some("read file first to check line count".into())
} else {
Some("fix error and retry".into())
};
CliResult {
status: "error".into(),
message: Some(msg),
ai_hint,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::Digest;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_file(dir: &TempDir, name: &str, content: &str) -> String {
let path = dir.path().join(name);
fs::write(&path, content).unwrap();
path.to_str().unwrap().to_string()
}
fn read_file(path: impl AsRef<std::path::Path>) -> String {
fs::read_to_string(path).unwrap()
}
#[test]
fn test_hex_decode_valid() {
assert_eq!(hex_decode("48656c6c6f").unwrap(), "Hello");
}
#[test]
fn test_hex_decode_empty() {
assert_eq!(hex_decode("").unwrap(), "");
}
#[test]
fn test_hex_decode_mixed_case() {
assert_eq!(hex_decode("4A6F62").unwrap(), "Job");
}
#[test]
fn test_hex_decode_non_hex_chars() {
assert!(hex_decode("zz").is_err());
}
#[test]
fn test_hex_decode_odd_length() {
assert!(hex_decode("48650").is_err());
}
#[test]
fn test_cmd_splice_replace_single_line() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "line1\nline2\nline3\n");
let r = cmd_splice(&path, 2, 2, "hex", false, false, false, None);
assert_eq!(r.status, "ok");
assert_eq!(r.lines_removed, 1);
assert_eq!(r.lines_inserted, 1);
let content = read_file(&path);
assert_eq!(content, "line1\nhex\nline3\n");
}
#[test]
fn test_cmd_splice_preserves_missing_trailing_newline() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "line1\nline2");
let r = cmd_splice(&path, 2, 2, "new", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "line1\nnew");
assert!(!content.ends_with('\n'));
}
#[test]
fn test_cmd_splice_preserves_existing_trailing_newline() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "line1\nline2\n");
let r = cmd_splice(&path, 2, 2, "new", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "line1\nnew\n");
assert!(content.ends_with('\n'));
}
#[test]
fn test_cmd_splice_replace_range() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\nd\ne\n");
let r = cmd_splice(&path, 2, 4, "X\nY", false, false, false, None);
assert_eq!(r.status, "ok");
assert_eq!(r.lines_removed, 3);
assert_eq!(r.lines_inserted, 2);
let content = read_file(&path);
assert_eq!(content, "a\nX\nY\ne\n");
}
#[test]
fn test_cmd_splice_insert_at_end() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let r = cmd_splice(&path, 2, 2, "c", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a\nc\n");
}
#[test]
fn test_cmd_splice_insert_at_end_start_gt_end() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let r = cmd_splice(&path, 3, 2, "c", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a\nb\nc\n");
}
#[test]
fn test_cmd_splice_insert_at_end_start_eq_end() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let r = cmd_splice(&path, 3, 3, "c", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a\nb\nc\n");
}
#[test]
fn test_cmd_splice_out_of_bounds() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let r = cmd_splice(&path, 10, 20, "x", false, false, false, None);
assert_eq!(r.status, "error");
assert!(r.message.as_deref().unwrap().contains("out of bounds"));
}
#[test]
fn test_cmd_splice_start_zero() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let r = cmd_splice(&path, 0, 1, "x", false, false, false, None);
assert_eq!(r.status, "error");
}
#[test]
fn test_cmd_splice_delete_single_line() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 2, "", false, false, false, None);
assert_eq!(r.status, "ok");
assert_eq!(r.lines_removed, 1);
assert_eq!(r.lines_inserted, 0);
let content = read_file(&path);
assert_eq!(content, "a\nc\n");
}
#[test]
fn test_cmd_splice_delete_range() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\nd\ne\n");
let r = cmd_splice(&path, 2, 4, "", false, false, false, None);
assert_eq!(r.status, "ok");
assert_eq!(r.lines_removed, 3);
assert_eq!(r.lines_inserted, 0);
let content = read_file(&path);
assert_eq!(content, "a\ne\n");
}
#[test]
fn test_cmd_splice_dry_run_no_change() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\n");
let original = read_file(&path);
let r = cmd_splice(&path, 2, 2, "7878", true, false, false, None);
assert_eq!(r.status, "dry_run");
let after = read_file(&path);
assert_eq!(original, after);
}
#[test]
fn test_cmd_splice_dry_run_delete() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\n");
let original = read_file(&path);
let r = cmd_splice(&path, 1, 2, "", true, false, false, None);
assert_eq!(r.status, "dry_run");
let after = read_file(&path);
assert_eq!(original, after);
}
#[test]
fn test_cmd_manifest_batch() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "line1\nline2\nline3\nline4\nline5\n");
let manifest =
r#"[{"start": 1, "end": 1, "hex": "78"}, {"start": 3, "end": 4, "delete": true}]"#;
let manifest_path = create_file(&dir, "ops.json", manifest);
let r = cmd_manifest(&path, Some(&manifest_path), false, false, false, None);
assert_eq!(r.status, "ok");
assert_eq!(r.operations, Some(2));
let content = read_file(&path);
assert_eq!(content, "x\nline2\nline5\n");
}
#[test]
fn test_cmd_manifest_dry_run() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\nc\n");
let original = read_file(&path);
let manifest = r#"[{"start": 1, "end": 1, "hex": "78"}]"#;
let manifest_path = create_file(&dir, "ops.json", manifest);
let r = cmd_manifest(&path, Some(&manifest_path), true, false, false, None);
assert_eq!(r.status, "dry_run");
let after = read_file(&path);
assert_eq!(original, after);
}
#[test]
fn test_cmd_manifest_bad_json() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let manifest_path = create_file(&dir, "ops.json", "not json");
let r = cmd_manifest(&path, Some(&manifest_path), false, false, false, None);
assert_eq!(r.status, "error");
assert!(r.message.as_deref().unwrap().contains("parse manifest"));
}
#[test]
fn test_cmd_manifest_out_of_bounds() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "test.txt", "a\nb\n");
let manifest_path = create_file(
&dir,
"ops.json",
r#"[{"start": 10, "end": 15, "delete": true}]"#,
);
let r = cmd_manifest(&path, Some(&manifest_path), false, false, false, None);
assert_eq!(r.status, "error");
assert!(r.message.as_deref().unwrap().contains("out of bounds"));
}
#[test]
fn test_cmd_undo_no_backup() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_no_backup_unique_12345.txt", "a\n");
let r = cmd_undo(&path);
assert_eq!(r.status, "error");
assert!(r.message.as_deref().unwrap().contains("no backup"));
}
#[test]
fn test_cmd_undo_restores() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_restores_unique_67890.txt", "original\n");
let _ = cmd_splice(&path, 1, 1, "xx", false, false, false, None);
let content = read_file(&path);
assert_ne!(content, "original\n");
let r = cmd_undo(&path);
assert_eq!(r.status, "restored");
let restored = read_file(&path);
assert_eq!(restored, "original\n");
}
#[test]
fn test_cmd_undo_multi_step() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "multi_undo.txt", "v1\n");
cmd_splice(&path, 1, 1, "v2", false, false, false, None); cmd_splice(&path, 1, 1, "v3", false, false, false, None);
assert_eq!(read_file(&path), "v3\n");
cmd_undo(&path); assert_eq!(read_file(&path), "v2\n");
cmd_undo(&path); assert_eq!(read_file(&path), "v1\n");
let r = cmd_undo(&path); assert_eq!(r.status, "error");
}
#[test]
fn test_cmd_encode() {
let r = cmd_encode("hello");
assert_eq!(r.status, "encoded");
assert_eq!(r.message.unwrap(), "68656c6c6f");
}
#[test]
fn test_result_serializes_json() {
let r = CliResult {
status: "ok".into(),
file: Some("test.rs".into()),
lines_removed: 2,
lines_inserted: 3,
total_lines: Some(10),
..Default::default()
};
let json = serde_json::to_string(&r).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["status"], "ok");
assert_eq!(v["file"], "test.rs");
assert_eq!(v["lines_removed"], 2);
assert!(v.get("message").is_none());
}
#[test]
fn test_line_shift_serialized_in_json() {
let r = CliResult {
status: "ok".into(),
lines_removed: 2,
lines_inserted: 3,
total_lines: Some(10),
line_shift: Some(1),
..Default::default()
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("line_shift"));
assert!(json.contains("1"));
}
#[test]
fn test_context_verification_match() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "ctx_test.txt", "a\nb\nc\nd\ne\nf\ng\nh\n");
let original = read_file(&path);
let _lines: Vec<String> = original.split_inclusive('\n').map(String::from).collect();
let mut hasher = sha2::Sha256::new();
hasher.update(b"a\nb\n");
hasher.update(b"d\ne\nf\n");
let hash = moesniper::hex_encode(&hasher.finalize());
let short_hash = &hash[..16];
let r = cmd_splice(&path, 3, 3, "NEW", false, false, false, Some(short_hash));
assert_eq!(r.status, "ok");
}
#[test]
fn test_cmd_context() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "ctx_test.txt", "a\nb\nc\nd\ne\nf\ng\nh\n");
let mut hasher = sha2::Sha256::new();
hasher.update(b"a\nb\n");
hasher.update(b"d\ne\nf\n");
let hash = moesniper::hex_encode(&hasher.finalize());
let short_hash = &hash[..16];
let r = cmd_context(&path, 3, 3);
assert_eq!(r.status, "ok");
assert_eq!(r.message.unwrap(), short_hash);
}
#[test]
fn test_context_verification_mismatch() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "ctx_test.txt", "a\nb\nc\nd\ne\nf\ng\nh\n");
let r = cmd_splice(
&path,
3,
3,
"NEW",
false,
false,
false,
Some("0000000000000000"),
);
assert_eq!(r.status, "error");
let msg = r.message.unwrap();
assert!(msg.contains("context mismatch"));
}
#[test]
fn test_manifest_promotion_after_multiple_edits() {
let dir = TempDir::new().unwrap();
let path = create_file(
&dir,
"promo_test.txt",
"line1\nline2\nline3\nline4\nline5\n",
);
cmd_splice(&path, 1, 1, "a", false, false, false, None);
cmd_splice(&path, 2, 2, "b", false, false, false, None);
cmd_splice(&path, 3, 3, "c", false, false, false, None);
let r = cmd_splice(&path, 4, 4, "d", false, false, false, None);
assert_eq!(r.status, "ok");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(
hint.contains("manifest"),
"Expected manifest promotion hint, got: {}",
hint
);
}
#[test]
fn test_run_help_success() {
let args = vec!["--help".to_string()];
let _ = run(args);
}
#[test]
fn test_run_version_success() {
let args = vec!["--version".to_string()];
let _ = run(args);
}
#[test]
fn test_run_invalid_args() {
let args = vec!["invalid_command".to_string()];
let _ = run(args);
}
#[test]
fn test_file_not_found() {
let r = cmd_splice(
"/tmp/no_such_file_12345.txt",
1,
1,
"78",
false,
false,
false,
None,
);
assert_eq!(r.status, "error");
let msg = r.message.as_deref().unwrap().to_lowercase();
assert!(
msg.contains("read") || msg.contains("metadata") || msg.contains("no such file"),
"expected file-related error, got: {}",
msg
);
}
#[test]
fn test_cmd_splice_delete_last_line_preserves_non_termination_if_possible() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "no_trailing.txt", "a\nb");
let r = cmd_splice(&path, 2, 2, "", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a");
}
#[test]
fn test_single_line_file() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "one.txt", "only\n");
let r = cmd_splice(&path, 1, 1, "new", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "new\n");
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_dry_run_never_modifies_file(
content in "[a-z\n]{1,100}",
replacement in "[a-z\n]{0,50}",
line_num in 1usize..10
) {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "prop_test.txt", &content);
let original = read_file(&path);
let lines: Vec<&str> = original.lines().collect();
if lines.is_empty() || line_num > lines.len() {
return Ok(());
}
let _ = cmd_splice(&path, line_num, line_num, &replacement, true, false, false, None);
let after = read_file(&path);
prop_assert_eq!(original, after);
}
#[test]
fn prop_splice_preserves_lines_outside_range(
content in "[a-z]{1,5}\n".prop_map(|s| s.repeat(3)),
replacement in "[a-z]{1,5}",
start in 1usize..=2,
end in 2usize..=3
) {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "prop_test.txt", &content);
let lines_before: Vec<&str> = content.lines().collect();
if start > end || end > lines_before.len() {
return Ok(());
}
let _ = cmd_splice(&path, start, end, &replacement, false, false, false, None);
let after = read_file(&path);
let lines_after: Vec<&str> = after.lines().collect();
for i in 0..(start - 1).min(lines_before.len()) {
if i < lines_after.len() {
prop_assert_eq!(lines_before[i], lines_after[i]);
}
}
}
#[test]
fn prop_hex_decode_roundtrip(s in "[0-7][0-9A-Fa-f]".prop_map(|s| {
// Only test ASCII-range hex (00-7F) which always produces valid UTF-8
if s.len() % 2 == 1 { s[..s.len()-1].to_string() } else { s }
})) {
let result = hex_decode(&s);
prop_assert!(result.is_ok());
}
#[test]
fn prop_undo_restores_original(
content in "[a-z\n]{1,50}",
replacement in "[a-z\n]{1,30}",
line_num in 1usize..5
) {
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = {
struct Guard(PathBuf);
impl Drop for Guard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } }
let g = Guard(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
g
};
let path = create_file(&dir, "prop_undo_test.txt", &content);
let original = read_file(&path);
let lines: Vec<&str> = original.lines().collect();
if lines.is_empty() || line_num > lines.len() {
return Ok(());
}
let _ = cmd_splice(&path, line_num, line_num, &replacement, false, false, false, None);
let _ = cmd_undo(&path);
let restored = read_file(&path);
prop_assert_eq!(original, restored);
}
#[test]
fn prop_splice_result_counts_match(
content in "[a-z]{1,5}\n".prop_map(|s| s.repeat(2)),
replacement in "[a-z\n]{1,20}"
) {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "prop_counts.txt", &content);
let lines_before: Vec<&str> = content.lines().collect();
if lines_before.len() < 2 {
return Ok(());
}
let r = cmd_splice(&path, 1, 2, &replacement, false, false, false, None);
let after = read_file(&path);
let lines_after: Vec<&str> = after.lines().collect();
prop_assert_eq!(r.total_lines, Some(lines_after.len()));
}
}
#[test]
fn test_ai_hint_after_splice() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "hint_test.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 2, "xx", false, false, false, None);
assert_eq!(r.status, "ok");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(hint.starts_with("verify:"));
assert!(hint.contains("lines 2-2"));
}
#[test]
fn test_ai_hint_after_delete() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "hint_test.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 2, "", false, false, false, None);
assert_eq!(r.status, "ok");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(hint.starts_with("verify:"));
assert!(hint.contains("around line"));
}
#[test]
fn test_ai_hint_after_dry_run() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "hint_test.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 1, 1, "xx", true, false, false, None);
assert_eq!(r.status, "dry_run");
assert!(r.ai_hint.is_some());
}
#[test]
fn test_ai_hint_after_error_not_found() {
let r = cmd_splice("/no/such/file.txt", 1, 1, "xx", false, false, false, None);
assert_eq!(r.status, "error");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(hint.contains("check path") || hint.contains("fix error"));
}
#[test]
fn test_ai_hint_after_error_out_of_bounds() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "hint_test.txt", "a\nb\n");
let r = cmd_splice(&path, 10, 20, "xx", false, false, false, None);
assert_eq!(r.status, "error");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(hint.contains("line count"));
}
#[test]
fn test_ai_hint_after_undo() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_hint.txt", "original\n");
let _ = cmd_splice(&path, 1, 1, "xx", false, false, false, None);
let r = cmd_undo(&path);
assert_eq!(r.status, "restored");
assert!(r.ai_hint.is_some());
let hint = r.ai_hint.unwrap();
assert!(hint.contains("verify restore"));
}
#[test]
fn test_ai_hint_serialize_excluded_without_json() {
let r = CliResult {
status: "ok".into(),
ai_hint: Some("test hint".into()),
..Default::default()
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("ai_hint"));
}
#[test]
fn test_dry_run_does_not_create_sniper_dir() {
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = {
struct Guard(PathBuf);
impl Drop for Guard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } }
let g = Guard(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
g
};
let sniper_dir = dir.path().join(".sniper");
assert!(!sniper_dir.exists(), ".sniper/ should not exist before dry-run");
let path = create_file(&dir, "dry_test.txt", "line1\nline2\nline3\n");
let r = cmd_splice(&path, 2, 2, "7878", true, false, false, None);
assert_eq!(r.status, "dry_run", "dry-run should succeed");
assert!(
!sniper_dir.exists(),
"BUG: dry-run created .sniper/ directory (state leakage via lock acquisition)"
);
}
#[test]
fn test_dry_run_does_not_create_backups() {
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = {
struct Guard(PathBuf);
impl Drop for Guard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } }
let g = Guard(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
g
};
let path = create_file(&dir, "dry_nobackup.txt", "line1\nline2\nline3\n");
let _ = cmd_splice(&path, 2, 2, "7878", true, false, false, None);
let sniper_dir = dir.path().join(".sniper");
if sniper_dir.exists() {
let entries: Vec<_> = std::fs::read_dir(&sniper_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
entries.is_empty(),
"BUG: dry-run created {} entries in .sniper/",
entries.len()
);
}
}
#[test]
fn test_dry_run_json_excludes_risk_telemetry() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "dry_json_risk.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 2, "7878", true, false, false, None);
assert_eq!(r.status, "dry_run");
assert!(r.risk.is_none(), "BUG: dry-run leaks risk telemetry in result struct");
assert!(r.recommended_action.is_none(), "BUG: dry-run leaks recommended_action in result struct");
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("\"risk\""), "BUG: dry-run JSON contains 'risk' field");
assert!(!json.contains("\"recommended_action\""), "BUG: dry-run JSON contains 'recommended_action' field");
}
#[test]
fn test_manifest_dry_run_json_excludes_risk_telemetry() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "manifest_dry_risk.txt", "a\nb\nc\n");
let manifest_path = create_file(&dir, "ops.json", r#"[{"start": 1, "end": 1, "hex": "78"}]"#);
let r = cmd_manifest(&path, Some(&manifest_path), true, false, false, None);
assert_eq!(r.status, "dry_run");
assert!(r.risk.is_none(), "BUG: manifest dry-run leaks risk telemetry");
assert!(r.recommended_action.is_none(), "BUG: manifest dry-run leaks recommended_action");
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("\"risk\""), "BUG: manifest dry-run JSON contains 'risk'");
assert!(!json.contains("\"recommended_action\""), "BUG: manifest dry-run JSON contains 'recommended_action'");
}
#[test]
fn test_dry_run_then_real_edit_file_state_correct() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "dry_then_real.txt", "line1\nline2\nline3\n");
let original = read_file(&path);
let r_dry = cmd_splice(&path, 2, 2, "NEW", true, false, false, None);
assert_eq!(r_dry.status, "dry_run");
assert_eq!(read_file(&path), original, "dry-run modified the file!");
let r_real = cmd_splice(&path, 1, 1, "FIRST", false, false, false, None);
assert_eq!(r_real.status, "ok");
let after = read_file(&path);
assert_eq!(after, "FIRST\nline2\nline3\n");
}
#[test]
fn test_manifest_dry_run_then_real_edit() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "man_dry_then_real.txt", "a\nb\nc\nd\n");
let original = read_file(&path);
let manifest_path = create_file(
&dir, "ops.json",
r#"[{"start": 2, "end": 2, "hex": "78"}]"#,
);
let r_dry = cmd_manifest(&path, Some(&manifest_path), true, false, false, None);
assert_eq!(r_dry.status, "dry_run");
assert_eq!(read_file(&path), original, "manifest dry-run modified the file!");
let r_real = cmd_manifest(&path, Some(&manifest_path), false, false, false, None);
assert_eq!(r_real.status, "ok");
assert_eq!(read_file(&path), "a\nx\nc\nd\n");
}
#[test]
#[cfg(unix)]
fn test_cmd_splice_preserves_restrictive_permissions_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "perms_0600.txt", "secret\n");
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms).unwrap();
let r = cmd_splice(&path, 1, 1, "x", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "x\n");
let final_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
final_mode, 0o600,
"BUG: permissions changed from 0o600 to 0o{:o}",
final_mode
);
}
#[test]
#[cfg(unix)]
fn test_cmd_splice_preserves_readonly_permissions_0400() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "perms_0400.txt", "readonly\n");
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&path, perms).unwrap();
let r = cmd_splice(&path, 1, 1, "x", false, false, false, None);
if r.status == "ok" {
let content = read_file(&path);
assert_eq!(content, "x\n");
let final_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
final_mode, 0o400,
"BUG: permissions changed from 0o400 to 0o{:o} after successful edit",
final_mode
);
}
}
#[test]
#[cfg(unix)]
fn test_undo_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_perms.txt", "original\n");
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms).unwrap();
let r_edit = cmd_splice(&path, 1, 1, "x", false, false, false, None);
assert_eq!(r_edit.status, "ok");
let mode_after_edit = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode_after_edit, 0o600,
"BUG: permissions lost after edit (0o600 -> 0o{:o})",
mode_after_edit
);
let r_undo = cmd_undo(&path);
assert_eq!(r_undo.status, "restored");
let content = read_file(&path);
assert_eq!(content, "original\n");
let mode_after_undo = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode_after_undo, 0o600,
"BUG: permissions lost after undo (0o600 -> 0o{:o})",
mode_after_undo
);
}
#[test]
fn test_cmd_splice_creates_new_file() {
let dir = TempDir::new().unwrap();
let nonexistent = dir.path().join("brand_new.txt");
let r = cmd_splice(
nonexistent.to_str().unwrap(),
1,
1,
"7878",
false,
false,
false,
None,
);
assert_eq!(r.status, "error", "Editing nonexistent file must return error, not crash");
}
#[test]
fn test_undo_after_dry_run_only_errors() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_after_dry.txt", "original\n");
let r_dry = cmd_splice(&path, 1, 1, "7878", true, false, false, None);
assert_eq!(r_dry.status, "dry_run");
let r_undo = cmd_undo(&path);
assert_eq!(r_undo.status, "error",
"BUG: undo after dry-run only should error, got status={}",
r_undo.status
);
assert!(
r_undo.message.as_deref().unwrap_or("").contains("no backup"),
"BUG: undo error should mention 'no backup', got: {:?}",
r_undo.message
);
assert_eq!(read_file(&path), "original\n");
}
#[test]
fn test_undo_never_edited_errors() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "never_edited.txt", "pristine\n");
let original = read_file(&path);
let r = cmd_undo(&path);
assert_eq!(r.status, "error",
"BUG: undo on never-edited file should error, got status={}",
r.status
);
assert!(
r.message.as_deref().unwrap_or("").contains("no backup"),
"BUG: error message should mention 'no backup', got: {:?}",
r.message
);
assert_eq!(read_file(&path), original);
}
#[test]
fn test_double_undo_second_fails() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "double_undo.txt", "original\n");
let r_edit = cmd_splice(&path, 1, 1, "edited", false, false, false, None);
assert_eq!(r_edit.status, "ok");
assert_eq!(read_file(&path), "edited\n");
let r_undo1 = cmd_undo(&path);
assert_eq!(r_undo1.status, "restored");
assert_eq!(read_file(&path), "original\n");
let r_undo2 = cmd_undo(&path);
assert_eq!(r_undo2.status, "error",
"BUG: second undo should fail (backup already consumed), got status={}",
r_undo2.status
);
assert!(
r_undo2.message.as_deref().unwrap_or("").contains("no backup"),
"BUG: second undo should report 'no backup', got: {:?}",
r_undo2.message
);
assert_eq!(read_file(&path), "original\n");
}
#[test]
fn test_undo_stack_multiple_edits() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "undo_stack.txt", "v0\n");
cmd_splice(&path, 1, 1, "v1", false, false, false, None);
cmd_splice(&path, 1, 1, "v2", false, false, false, None);
cmd_splice(&path, 1, 1, "v3", false, false, false, None);
assert_eq!(read_file(&path), "v3\n");
let r1 = cmd_undo(&path);
assert_eq!(r1.status, "restored");
assert_eq!(read_file(&path), "v2\n");
let r2 = cmd_undo(&path);
assert_eq!(r2.status, "restored");
assert_eq!(read_file(&path), "v1\n");
let r3 = cmd_undo(&path);
assert_eq!(r3.status, "restored");
assert_eq!(read_file(&path), "v0\n");
let r4 = cmd_undo(&path);
assert_eq!(r4.status, "error");
assert_eq!(read_file(&path), "v0\n");
}
#[test]
fn test_manifest_dry_run_does_not_create_backups() {
let dir = TempDir::new().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let _cwd_guard = {
struct Guard(PathBuf);
impl Drop for Guard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } }
let g = Guard(original_cwd);
std::env::set_current_dir(dir.path()).unwrap();
g
};
let path = create_file(&dir, "man_dry_nobackup.txt", "a\nb\nc\n");
let manifest_path = create_file(
&dir, "ops.json",
r#"[{"start": 2, "end": 2, "delete": true}]"#,
);
let r = cmd_manifest(&path, Some(&manifest_path), true, false, false, None);
assert_eq!(r.status, "dry_run");
assert!(
r.backup.is_none(),
"BUG: manifest dry-run created a backup: {:?}",
r.backup
);
}
#[test]
fn test_dry_run_delete_does_not_modify_file() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "dry_delete.txt", "a\nb\nc\n");
let original = read_file(&path);
let r = cmd_splice(&path, 2, 2, "", true, false, false, None);
assert_eq!(r.status, "dry_run");
assert_eq!(read_file(&path), original, "BUG: dry-run delete modified the file!");
}
#[test]
fn test_dry_run_json_schema_integrity() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "dry_schema.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 2, "7878", true, false, false, None);
assert_eq!(r.status, "dry_run");
let json = serde_json::to_string_pretty(&r).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["status"], "dry_run");
assert!(v.get("file").is_some(), "dry-run must include 'file' field");
assert!(v.get("lines_removed").is_some(), "dry-run must include 'lines_removed'");
assert!(v.get("lines_inserted").is_some(), "dry-run must include 'lines_inserted'");
assert!(v.get("ai_hint").is_some(), "dry-run must include 'ai_hint'");
assert!(v.get("line_shift").is_some(), "dry-run must include 'line_shift'");
assert!(v.get("risk").is_none(), "BUG: dry-run JSON contains 'risk'");
assert!(v.get("recommended_action").is_none(), "BUG: dry-run JSON contains 'recommended_action'");
assert!(v.get("backup").is_none(), "BUG: dry-run JSON contains 'backup'");
assert!(v.get("total_lines").is_none(), "BUG: dry-run JSON contains 'total_lines'");
}
#[test]
fn test_manifest_dry_run_json_schema_integrity() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "man_dry_schema.txt", "a\nb\nc\nd\n");
let manifest_path = create_file(
&dir, "ops.json",
r#"[{"start": 2, "end": 2, "hex": "78"}, {"start": 4, "end": 4, "delete": true}]"#,
);
let r = cmd_manifest(&path, Some(&manifest_path), true, false, false, None);
assert_eq!(r.status, "dry_run");
let json = serde_json::to_string_pretty(&r).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["status"], "dry_run");
assert!(v.get("operations").is_some(), "manifest dry-run must include 'operations'");
assert!(v.get("manifest_ops").is_some(), "manifest dry-run must include 'manifest_ops'");
assert!(v.get("risk").is_none(), "BUG: manifest dry-run JSON contains 'risk'");
assert!(v.get("recommended_action").is_none(), "BUG: manifest dry-run JSON contains 'recommended_action'");
assert!(v.get("backup").is_none(), "BUG: manifest dry-run JSON contains 'backup'");
}
#[test]
fn bug_probe_splice_start1_end0_insert_before_line1() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "ins_before.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 1, 0, "X", false, false, false, None);
if r.status == "ok" {
let content = read_file(&path);
assert_eq!(content, "X\na\nb\nc\n",
"CLI allows start=1,end=0 as insert-before-line-1. Python rejects (end<start). Inconsistency?");
} else {
assert!(r.message.as_deref().unwrap().contains("out of bounds")
|| r.message.as_deref().unwrap().contains("invalid"),
"Expected bounds error if rejected. Got: {:?}", r.message);
}
}
#[test]
fn bug_probe_manifest_start1_end0() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_1_0.txt", "a\nb\nc\n");
let manifest = r#"[{"start": 1, "end": 0, "hex": "58"}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
if r.status == "ok" {
let content = read_file(&path);
assert_eq!(content, "X\na\nb\nc\n",
"Manifest start=1,end=0 inserts before line 1. Got: {:?}", content);
} else {
assert!(r.message.as_deref().unwrap().contains("out of bounds"),
"Expected bounds error. Got: {:?}", r.message);
}
}
#[test]
fn bug_probe_empty_file_insert() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "empty_insert.txt", "");
let r = cmd_splice(&path, 1, 1, "X", false, false, false, None);
assert_eq!(r.status, "ok",
"Insert into empty file should succeed via insert-at-end exception. Got: {:?}", r.message);
let content = read_file(&path);
assert_eq!(content, "X",
"Empty file + hex 58 should produce 'X' (no trailing nl). Got: {:?}", content);
}
#[test]
fn bug_probe_manifest_empty_file_insert() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_empty.txt", "");
let manifest = r#"[{"start": 1, "end": 1, "hex": "58"}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
assert_eq!(r.status, "ok",
"Manifest insert into empty file. Got: {:?}", r.message);
let content = read_file(&path);
assert_eq!(content, "X", "Expected 'X'. Got: {:?}", content);
}
#[test]
fn bug_probe_delete_all_lines() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "del_all.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 1, 3, "", false, false, false, None);
assert_eq!(r.status, "ok",
"Delete all lines should succeed. Got: {:?}", r.message);
let content = read_file(&path);
assert_eq!(content, "",
"Deleting all lines from file with trailing newline leaves empty file. Got: {:?}", content);
}
#[test]
fn bug_probe_delete_all_lines_no_trailing() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "del_all_no.txt", "a\nb");
let r = cmd_splice(&path, 1, 2, "", false, false, false, None);
assert_eq!(r.status, "ok",
"Delete all lines from no-trailing-nl file. Got: {:?}", r.message);
let content = read_file(&path);
assert_eq!(content, "",
"Deleting all lines from file without trailing newline leaves empty file. Got: {:?}", content);
}
#[test]
fn bug_probe_delete_last_line_with_nl() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "del_last_nl.txt", "a\nb\n");
let r = cmd_splice(&path, 2, 2, "", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a\n",
"Delete last line of file with trailing newline. Got: {:?}", content);
}
#[test]
fn bug_probe_delete_last_line_no_nl() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "del_last_nn.txt", "a\nb");
let r = cmd_splice(&path, 2, 2, "", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "a",
"Delete last line of file without trailing newline. Got: {:?}", content);
}
#[test]
fn bug_probe_end_beyond_file_length() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "end_beyond.txt", "a\n");
let r = cmd_splice(&path, 1, 2, "X", false, false, false, None);
assert_eq!(r.status, "error",
"end=2 beyond file length 1 should be rejected. Got status: {}", r.status);
assert!(r.message.as_deref().unwrap().contains("out of bounds"),
"Expected out-of-bounds, got: {:?}", r.message);
}
#[test]
fn bug_probe_start_beyond_len_end_valid() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "sbeyond.txt", "a\nb\n");
let r = cmd_splice(&path, 10, 2, "X", false, false, false, None);
assert_eq!(r.status, "error",
"start=10 > end+1=3 should reject. Got: {:?}", r.message);
}
#[test]
fn bug_probe_splice_start2_end0_rejected() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "s2e0.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 0, "X", false, false, false, None);
assert_eq!(r.status, "error",
"start=2,end=0: start>end+1 should reject. Got: {:?}", r.message);
}
#[test]
fn bug_probe_insert_at_very_end() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "append.txt", "a\n");
let r = cmd_splice(&path, 2, 2, "b", false, false, false, None);
assert_eq!(r.status, "ok",
"Append at end (start=2,end=2 on 1-line file). Got: {:?}", r.message);
let content = read_file(&path);
assert_eq!(content, "a\nb\n",
"Append should add line with trailing newline. Got: {:?}", content);
}
#[test]
fn bug_probe_single_line_no_nl_replace() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "one_nnl.txt", "only");
let r = cmd_splice(&path, 1, 1, "new", false, false, false, None);
assert_eq!(r.status, "ok");
let content = read_file(&path);
assert_eq!(content, "new",
"Single-line no-nl: should preserve no trailing newline. Got: {:?}", content);
}
#[test]
fn bug_probe_manifest_start_zero() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_zero.txt", "a\nb\n");
let manifest = r#"[{"start": 0, "end": 1, "delete": true}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
assert_eq!(r.status, "error",
"Manifest start=0 must be rejected (1-indexed). Got: {:?}", r.message);
assert!(r.message.as_deref().unwrap().contains("out of bounds"),
"Expected bounds error, got: {:?}", r.message);
}
#[test]
fn bug_probe_manifest_silent_noop() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_noop.txt", "a\nb\nc\n");
let manifest = r#"[{"start": 1, "end": 1}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
assert_eq!(r.status, "error",
"Silent no-op: op with no hex and no delete must return error. Fixed. Status: {}", r.status);
assert_eq!(r.lines_removed, 0, "No lines should be removed in silent no-op");
assert_eq!(r.lines_inserted, 0, "No lines should be inserted in silent no-op");
}
#[test]
fn bug_probe_manifest_same_start_overlap() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_overlap.txt", "a\nb\nc\nd\ne\n");
let manifest = r#"[
{"start": 3, "end": 3, "delete": true},
{"start": 3, "end": 3, "hex": "58"}
]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
assert_eq!(r.status, "error",
"Expected error for same-start manifest ops, got status={}", r.status);
assert!(r.message.as_deref().unwrap().contains("overlapping manifest operations"),
"Expected 'overlapping manifest operations' error, got: {:?}", r.message);
}
#[test]
fn bug_probe_manifest_context_pre_loop_gate() {
let dir = TempDir::new().unwrap();
let content = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
let path = create_file(&dir, "mf_ctx_gate.txt", content);
let lines: Vec<String> = content.split_inclusive('\n').map(String::from).collect();
let ctx_hash = moesniper::compute_context_hash(&lines, 6, 6);
let short_hash = &ctx_hash[..16];
let manifest = format!(
r#"[
{{"start": 3, "end": 3, "hex": "4e455731"}},
{{"start": 6, "end": 6, "hex": "4e455732"}}
]"#
);
let r = cmd_manifest_impl(&path, &manifest, false, false, false, Some(short_hash));
assert_eq!(r.status, "ok",
"Pre-manifest context gate should pass (hash matches first op). \
Status={}, msg={:?}", r.status, r.message);
}
#[test]
fn bug_probe_manifest_context_single_op_works() {
let dir = TempDir::new().unwrap();
let content = "a\nb\nc\nd\ne\nf\ng\nh\n";
let path = create_file(&dir, "mf_ctx1.txt", content);
let lines: Vec<String> = content.split_inclusive('\n').map(String::from).collect();
let ctx_hash = moesniper::compute_context_hash(&lines, 3, 3);
let short_hash = &ctx_hash[..16];
let manifest = format!(r#"[{{"start": 3, "end": 3, "hex": "4e4557"}}]"#);
let r = cmd_manifest_impl(&path, &manifest, false, false, false, Some(short_hash));
assert_eq!(r.status, "ok",
"Single-op manifest with correct context hash should succeed. Got: {:?}", r.message);
}
#[test]
fn bug_probe_manifest_context_wrong_hash_fails() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_ctxbad.txt", "a\nb\nc\nd\ne\nf\ng\nh\n");
let manifest = r#"[{"start": 3, "end": 3, "hex": "4e4557"}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, Some("0000000000000000"));
assert_eq!(r.status, "error",
"Wrong context hash should fail. Got: {:?}", r.message);
assert!(r.message.as_deref().unwrap().contains("context mismatch"),
"Expected context mismatch, got: {:?}", r.message);
}
#[test]
fn bug_probe_manifest_both_delete_and_hex() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "mf_both.txt", "a\nb\n");
let manifest = r#"[{"start": 1, "end": 1, "delete": true, "hex": "58"}]"#;
let r = cmd_manifest_impl(&path, manifest, false, false, false, None);
assert_eq!(r.status, "error",
"Both delete and hex in same op must be rejected. Got: {:?}", r.message);
assert!(r.message.as_deref().unwrap().contains("Cannot both delete and insert"),
"Expected 'Cannot both delete and insert', got: {:?}", r.message);
}
#[test]
fn bug_python_parity_end_bound_looser_than_rust() {
let dir = TempDir::new().unwrap();
let path = create_file(&dir, "parity_end.txt", "a\nb\nc\n");
let r = cmd_splice(&path, 2, 4, "new", false, false, false, None);
assert_eq!(r.status, "error",
"Rust rejects end=lines.len()+1 when start < lines.len()+1 (Python allows this)");
}
}