use super::*;
struct Scratch {
_tmp: tempfile::TempDir,
root: PathBuf,
dir: PathBuf,
config: PathBuf,
}
impl Scratch {
fn new() -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let base = std::fs::canonicalize(tmp.path()).expect("canonical tempdir");
let dir = base.join("arch");
std::fs::create_dir_all(&dir).expect("architect dir");
Self {
root: base.join(".trusty-mpm"),
config: base.join("claude-config"),
dir,
_tmp: tmp,
}
}
fn transcript(&self, id: &str) {
let folder = self.config.join("projects").join(project_folder(&self.dir));
std::fs::create_dir_all(&folder).expect("project folder");
std::fs::write(folder.join(format!("{id}.jsonl")), "{}\n").expect("transcript");
}
fn resolve(&self) -> ConversationStart {
resolve_conversation(&self.root, &self.dir, Some(&self.config))
}
}
const ID: &str = "3f2b8c1e-5d4a-4b6f-9e2d-1a7c0b9e8f61";
#[test]
fn the_project_folder_name_matches_claude_code() {
assert_eq!(
project_folder(Path::new("/work/supervisor")),
"-work-supervisor"
);
assert_eq!(
project_folder(Path::new("/a/.claude/_b c")),
"-a--claude--b-c"
);
}
#[test]
fn the_claude_args_name_the_conversation() {
assert_eq!(
ConversationStart::Resume(ID.to_owned()).claude_args(),
["--resume".to_owned(), ID.to_owned()]
);
let fresh = ConversationStart::Fresh {
id: ID.to_owned(),
reason: None,
};
assert_eq!(
fresh.claude_args(),
["--session-id".to_owned(), ID.to_owned()]
);
assert_eq!(fresh.id(), ID);
}
#[test]
fn no_record_starts_fresh_without_a_reason() {
let s = Scratch::new();
match s.resolve() {
ConversationStart::Fresh { id, reason: None } => {
assert!(uuid::Uuid::try_parse(&id).is_ok(), "{id}");
}
other => panic!("expected a plain fresh start, got {other:?}"),
}
}
#[test]
fn a_recorded_conversation_with_a_transcript_is_resumed() {
let s = Scratch::new();
record_conversation(&s.root, &s.dir, ID).expect("record");
s.transcript(ID);
assert_eq!(s.resolve(), ConversationStart::Resume(ID.to_owned()));
assert!(
s.resolve()
.describe()
.contains(&format!("resuming conversation {ID}"))
);
}
#[test]
fn every_unusable_record_starts_fresh_and_says_why() {
fn write(s: &Scratch, body: &str) {
let path = conversation_path(&s.root);
std::fs::create_dir_all(path.parent().expect("parent")).expect("dir");
std::fs::write(&path, body).expect("write");
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
}
fn record_json(dir: &Path, id: &str) -> String {
serde_json::json!({ "project_dir": dir, "conversation_id": id }).to_string()
}
fn write_mode(s: &Scratch, mode: u32) {
use std::os::unix::fs::PermissionsExt as _;
record_conversation(&s.root, &s.dir, ID).expect("record");
s.transcript(ID);
let perms = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(conversation_path(&s.root), perms).expect("chmod");
}
type Setup = Box<dyn Fn(&Scratch)>;
let cases: Vec<(&str, Setup, &str)> = vec![
("corrupt", Box::new(|s| write(s, "{not json")), "is corrupt"),
(
"unknown field",
Box::new(|s| {
write(
s,
&format!(
r#"{{"project_dir":"{}","conversation_id":"{ID}","x":1}}"#,
s.dir.display()
),
)
}),
"is corrupt",
),
(
"another directory",
Box::new(|s| {
write(s, &record_json(&s.config, ID));
s.transcript(ID);
}),
"belongs to",
),
(
"not a uuid",
Box::new(|s| write(s, &record_json(&s.dir, "--dangerously-skip-permissions"))),
"not a valid conversation id",
),
(
"non-canonical uuid",
Box::new(|s| {
let upper = ID.to_uppercase();
write(s, &record_json(&s.dir, &upper));
s.transcript(&upper);
}),
"not a valid conversation id",
),
(
"missing transcript",
Box::new(|s| record_conversation(&s.root, &s.dir, ID).expect("record")),
"has no transcript",
),
(
"group-writable",
Box::new(|s| write_mode(s, 0o620)),
"writable by other users (mode 620)",
),
(
"world-writable",
Box::new(|s| write_mode(s, 0o602)),
"writable by other users (mode 602)",
),
(
"unreadable",
Box::new(|s| std::fs::create_dir_all(conversation_path(&s.root)).expect("dir")),
"could not be read",
),
(
"symlinked",
Box::new(|s| {
write_mode(s, 0o600);
let path = conversation_path(&s.root);
let real = path.with_extension("real");
std::fs::rename(&path, &real).expect("move the record");
std::os::unix::fs::symlink(&real, &path).expect("symlink");
}),
"could not be read",
),
];
for (name, setup, want) in cases {
let s = Scratch::new();
setup(&s);
match s.resolve() {
ConversationStart::Fresh {
id,
reason: Some(why),
} => {
assert!(why.contains(want), "{name}: {why}");
assert_ne!(id.to_lowercase(), ID, "{name}: reused the recorded id");
assert!(uuid::Uuid::try_parse(&id).is_ok(), "{name}: {id}");
let line = ConversationStart::Fresh {
id,
reason: Some(why),
}
.describe();
assert!(line.contains("was not resumed"), "{name}: {line}");
}
other => panic!("{name}: expected a fresh start with a reason, got {other:?}"),
}
}
let s = Scratch::new();
record_conversation(&s.root, &s.dir, ID).expect("record");
s.transcript(ID);
assert!(matches!(
resolve_conversation(&s.root, &s.dir, None),
ConversationStart::Fresh { reason: Some(why), .. } if why.contains("cannot be checked")
));
write_mode(&s, 0o644);
assert_eq!(s.resolve(), ConversationStart::Resume(ID.to_owned()));
clear_conversation(&s.root).expect("clear");
clear_conversation(&s.root).expect("clear an absent record");
assert!(matches!(
s.resolve(),
ConversationStart::Fresh { reason: None, .. }
));
}
#[test]
fn a_record_owned_by_another_user_is_refused() {
use std::os::unix::fs::MetadataExt as _;
let s = Scratch::new();
record_conversation(&s.root, &s.dir, ID).expect("record");
let path = conversation_path(&s.root);
let owner = std::fs::metadata(&path).expect("record").uid();
assert!(matches!(read_owned_by(&path, owner), Ok(Some(_))));
let other = owner.wrapping_add(1);
let why = read_owned_by(&path, other).expect_err("another owner's record was read");
assert!(
why.contains(&format!(
"is owned by uid {owner}, not by this user (uid {other})"
)),
"{why}"
);
}