use assert_cmd::Command;
use cqs::note::Note;
use predicates::prelude::*;
use serde_json::Value;
use serial_test::serial;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn cqs() -> Command {
#[allow(deprecated)]
Command::cargo_bin("cqs").expect("Failed to find cqs binary")
}
fn setup_notes_project() -> TempDir {
let dir = TempDir::new().expect("Failed to create temp dir");
let cqs_dir = dir.path().join(".cqs");
fs::create_dir_all(&cqs_dir).expect("Failed to create .cqs dir");
dir
}
fn notes_path(dir: &TempDir) -> PathBuf {
dir.path().join("docs/notes.toml")
}
fn read_notes(dir: &TempDir) -> Vec<Note> {
let path = notes_path(dir);
if !path.exists() {
return Vec::new();
}
cqs::parse_notes(&path).expect("parse_notes should succeed on test fixture")
}
fn notes_add_json(dir: &TempDir, text: &str, sentiment: &str, mentions: Option<&str>) -> Value {
let mut args: Vec<&str> = vec![
"--json",
"notes",
"add",
text,
"--sentiment",
sentiment,
"--no-reindex",
];
if let Some(m) = mentions {
args.push("--mentions");
args.push(m);
}
let output = cqs()
.args(&args)
.current_dir(dir.path())
.output()
.expect("cqs notes add failed to spawn");
assert!(
output.status.success(),
"cqs notes add failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("notes add JSON parse failed")
}
#[test]
#[serial]
fn test_notes_add_creates_file_and_persists() {
let dir = setup_notes_project();
assert!(
!notes_path(&dir).exists(),
"notes.toml should not exist pre-add"
);
let json = notes_add_json(&dir, "hello from CLI", "0.5", None);
assert_eq!(json["status"], "added");
assert_eq!(json["file"], "docs/notes.toml");
assert!(
json["text_preview"]
.as_str()
.unwrap()
.contains("hello from CLI"),
"text_preview should echo the note text, got {:?}",
json["text_preview"]
);
assert_eq!(json["type"], "pattern");
assert!(
notes_path(&dir).exists(),
"notes.toml should be created by add"
);
let notes = read_notes(&dir);
assert_eq!(notes.len(), 1, "parse_notes should return the added note");
assert_eq!(notes[0].text, "hello from CLI");
assert!(
(notes[0].sentiment - 0.5).abs() < 1e-6,
"sentiment round-trip failed: {}",
notes[0].sentiment
);
}
#[test]
#[serial]
fn test_notes_update_changes_text_and_sentiment() {
let dir = setup_notes_project();
notes_add_json(&dir, "old text body", "0.0", None);
cqs()
.args([
"--json",
"notes",
"update",
"old text body",
"--new-text",
"new text body",
"--new-sentiment",
"-1",
"--no-reindex",
])
.current_dir(dir.path())
.assert()
.success();
let notes = read_notes(&dir);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "new text body");
assert!(
(notes[0].sentiment - (-1.0)).abs() < 1e-6,
"sentiment should be -1.0 after update, got {}",
notes[0].sentiment
);
assert!(notes[0].is_warning());
}
#[test]
#[serial]
fn test_notes_remove_deletes_note() {
let dir = setup_notes_project();
notes_add_json(&dir, "note to remove", "0.0", None);
assert_eq!(read_notes(&dir).len(), 1);
cqs()
.args([
"--json",
"notes",
"remove",
"note to remove",
"--no-reindex",
])
.current_dir(dir.path())
.assert()
.success()
.stdout(predicate::str::contains("\"status\": \"removed\""));
let after = read_notes(&dir);
assert!(
after.is_empty(),
"read_notes should be empty after remove, got {} note(s)",
after.len()
);
}
#[test]
#[serial]
fn test_notes_add_update_remove_lifecycle() {
let dir = setup_notes_project();
notes_add_json(&dir, "lifecycle note", "0.5", Some("foo.rs,bar"));
let after_add = read_notes(&dir);
assert_eq!(after_add.len(), 1);
assert_eq!(after_add[0].text, "lifecycle note");
assert!(after_add[0].mentions.iter().any(|m| m == "foo.rs"));
assert!(after_add[0].mentions.iter().any(|m| m == "bar"));
cqs()
.args([
"--json",
"notes",
"update",
"lifecycle note",
"--new-sentiment",
"-0.5",
"--no-reindex",
])
.current_dir(dir.path())
.assert()
.success();
let after_update = read_notes(&dir);
assert_eq!(after_update.len(), 1);
assert!(
(after_update[0].sentiment - (-0.5)).abs() < 1e-6,
"expected sentiment -0.5 after update, got {}",
after_update[0].sentiment
);
assert!(after_update[0].mentions.iter().any(|m| m == "foo.rs"));
cqs()
.args(["notes", "remove", "lifecycle note", "--no-reindex"])
.current_dir(dir.path())
.assert()
.success();
assert!(read_notes(&dir).is_empty());
}
#[test]
#[serial]
fn test_notes_add_sentiment_clamps() {
let dir = setup_notes_project();
let json = notes_add_json(&dir, "clamp me", "5.0", None);
let sent = json["sentiment"].as_f64().unwrap();
assert!(
(sent - 1.0).abs() < 1e-6,
"sentiment 5.0 must clamp to 1.0 in JSON envelope, got {sent}"
);
let notes = read_notes(&dir);
assert_eq!(notes.len(), 1);
assert!(
(notes[0].sentiment - 1.0).abs() < 1e-6,
"stored sentiment must also be clamped, got {}",
notes[0].sentiment
);
}
#[test]
#[serial]
fn test_notes_update_missing_text_errors() {
let dir = setup_notes_project();
notes_add_json(&dir, "real note", "0.0", None);
cqs()
.args([
"notes",
"update",
"does not exist",
"--new-text",
"anything",
"--no-reindex",
])
.current_dir(dir.path())
.assert()
.failure();
let notes = read_notes(&dir);
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "real note");
}
#[test]
#[serial]
fn test_notes_add_rejects_empty_text() {
let dir = setup_notes_project();
cqs()
.args(["notes", "add", "", "--sentiment", "0", "--no-reindex"])
.current_dir(dir.path())
.assert()
.failure();
assert!(
!notes_path(&dir).exists(),
"Empty-text add must not create notes.toml"
);
}