use anyhow::{Context, Result};
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use similar::TextDiff;
use std::collections::HashMap;
use std::io::{self, Read};
use std::path::PathBuf;
use toolpath::v1;
#[derive(Subcommand, Debug)]
pub enum TrackOp {
Init {
#[arg(long)]
file: String,
#[arg(long)]
actor: String,
#[arg(long)]
actor_def: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
source: Option<String>,
#[arg(long)]
base_uri: Option<String>,
#[arg(long)]
base_ref: Option<String>,
#[arg(long)]
session_dir: Option<PathBuf>,
},
Step {
#[arg(long)]
session: PathBuf,
#[arg(long)]
seq: u64,
#[arg(long)]
parent_seq: u64,
#[arg(long)]
actor: Option<String>,
#[arg(long)]
time: Option<String>,
#[arg(long)]
source: Option<String>,
},
Visit {
#[arg(long)]
session: PathBuf,
#[arg(long)]
seq: u64,
#[arg(long)]
inherit_from: Option<u64>,
},
Note {
#[arg(long)]
session: PathBuf,
#[arg(long)]
intent: String,
},
Annotate {
#[arg(long)]
session: PathBuf,
#[arg(long)]
step: Option<String>,
#[arg(long)]
intent: Option<String>,
#[arg(long)]
source: Option<String>,
#[arg(long = "ref")]
refs: Vec<String>,
},
Export {
#[arg(long)]
session: PathBuf,
},
Close {
#[arg(long)]
session: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
},
List {
#[arg(long)]
session_dir: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TrackState {
version: u32,
file: String,
default_actor: String,
buffer_cache: HashMap<u64, String>,
seq_to_step: HashMap<u64, String>,
step_counter: u64,
created_at: String,
}
fn read_stdin() -> Result<String> {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("failed to read stdin")?;
Ok(buf)
}
fn now_iso8601() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn session_dir(explicit: Option<&PathBuf>) -> PathBuf {
explicit.cloned().unwrap_or_else(std::env::temp_dir)
}
fn load_session(path: &std::path::Path) -> Result<(v1::Path, TrackState)> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("failed to read session file: {}", path.display()))?;
let doc: v1::Document = serde_json::from_str(&data)
.with_context(|| format!("failed to parse session file: {}", path.display()))?;
let v1::Document::Path(mut path_doc) = doc else {
anyhow::bail!("session file is not a Path document: {}", path.display());
};
let track_value = path_doc
.meta
.as_mut()
.and_then(|m| m.extra.remove("track"))
.with_context(|| format!("session file missing meta.track: {}", path.display()))?;
let state: TrackState = serde_json::from_value(track_value)
.with_context(|| format!("failed to parse meta.track: {}", path.display()))?;
if let Some(meta) = &path_doc.meta
&& meta_is_empty(meta)
{
path_doc.meta = None;
}
Ok((path_doc, state))
}
fn save_session(path: &std::path::Path, doc: &v1::Path, state: &TrackState) -> Result<()> {
let mut doc = doc.clone();
let meta = doc.meta.get_or_insert_with(v1::PathMeta::default);
meta.extra.insert(
"track".to_string(),
serde_json::to_value(state).context("failed to serialize track state")?,
);
let wrapped = v1::Document::Path(doc);
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let tmp = tempfile::NamedTempFile::new_in(dir)
.context("failed to create temp file for atomic write")?;
serde_json::to_writer_pretty(&tmp, &wrapped).context("failed to serialize session")?;
tmp.persist(path)
.with_context(|| format!("failed to persist session file: {}", path.display()))?;
Ok(())
}
fn meta_is_empty(meta: &v1::PathMeta) -> bool {
meta.title.is_none()
&& meta.source.is_none()
&& meta.intent.is_none()
&& meta.refs.is_empty()
&& meta.actors.is_none()
&& meta.signatures.is_empty()
&& meta.extra.is_empty()
}
fn compute_diff(old: &str, new: &str) -> Option<String> {
let diff = TextDiff::from_lines(old, new);
let unified = diff.unified_diff().context_radius(3).to_string();
if unified.is_empty() {
None
} else {
Some(unified)
}
}
fn format_output(doc: v1::Document, pretty: bool) -> Result<String> {
if pretty {
doc.to_json_pretty()
} else {
doc.to_json()
}
.context("failed to serialize document")
}
struct InitConfig {
content: String,
file: String,
actor: String,
actors: Option<HashMap<String, v1::ActorDefinition>>,
title: Option<String>,
source: Option<String>,
base_uri: Option<String>,
base_ref: Option<String>,
session_dir: Option<PathBuf>,
}
fn init_session(config: InitConfig) -> Result<PathBuf> {
let now = now_iso8601();
let pid = std::process::id();
let ts_compact = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
let session_id = format!("track-{ts_compact}-{pid}");
let base = config.base_uri.map(|uri| v1::Base {
uri,
ref_str: config.base_ref,
});
let mut path_doc = v1::Path::new(&session_id, base, "none");
let has_meta = config.title.is_some() || config.source.is_some() || config.actors.is_some();
if has_meta {
path_doc.meta = Some(v1::PathMeta {
title: config.title,
source: config.source,
actors: config.actors,
..Default::default()
});
}
let mut buffer_cache = HashMap::new();
buffer_cache.insert(0u64, config.content);
let state = TrackState {
version: 1,
file: config.file,
default_actor: config.actor,
buffer_cache,
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: now,
};
let dir = session_dir(config.session_dir.as_ref());
let session_path = dir.join(format!("{session_id}.json"));
save_session(&session_path, &path_doc, &state)?;
Ok(session_path)
}
fn run_init(op: &TrackOp) -> Result<()> {
let TrackOp::Init {
file,
actor,
actor_def,
title,
source,
base_uri,
base_ref,
session_dir,
} = op
else {
unreachable!()
};
let content = read_stdin()?;
let actors: Option<HashMap<String, v1::ActorDefinition>> = actor_def
.as_deref()
.map(serde_json::from_str)
.transpose()
.context("failed to parse --actor-def JSON")?;
let session_path = init_session(InitConfig {
content,
file: file.clone(),
actor: actor.clone(),
actors,
title: title.clone(),
source: source.clone(),
base_uri: base_uri.clone(),
base_ref: base_ref.clone(),
session_dir: session_dir.clone(),
})?;
println!("{}", session_path.display());
Ok(())
}
#[derive(Debug, PartialEq)]
enum StepResult {
Created(String),
Skip,
}
fn record_step(
session_path: &std::path::Path,
content: String,
seq: u64,
parent_seq: u64,
actor_override: Option<String>,
time_override: Option<String>,
source_json: Option<&str>,
) -> Result<StepResult> {
let (mut path_doc, mut state) = load_session(session_path)?;
if state.buffer_cache.contains_key(&seq) {
return Ok(StepResult::Skip);
}
let parent_content = state
.buffer_cache
.get(&parent_seq)
.with_context(|| format!("parent seq {parent_seq} not found in buffer cache"))?
.clone();
let diff = compute_diff(&parent_content, &content);
state.buffer_cache.insert(seq, content);
let result = if let Some(diff_text) = diff {
state.step_counter += 1;
let step_id = format!("step-{:03}", state.step_counter);
let actor = actor_override.unwrap_or_else(|| state.default_actor.clone());
let timestamp = time_override.unwrap_or_else(now_iso8601);
let mut step =
v1::Step::new(&step_id, &actor, ×tamp).with_raw_change(&state.file, &diff_text);
if let Some(parent_step_id) = state.seq_to_step.get(&parent_seq)
&& !parent_step_id.is_empty()
{
step = step.with_parent(parent_step_id);
}
if let Some(src) = source_json {
let vcs_source: v1::VcsSource =
serde_json::from_str(src).context("failed to parse --source JSON as VcsSource")?;
step.meta.get_or_insert_with(v1::StepMeta::default).source = Some(vcs_source);
}
path_doc.steps.push(step);
state.seq_to_step.insert(seq, step_id.clone());
path_doc.path.head = step_id.clone();
StepResult::Created(step_id)
} else {
state.seq_to_step.insert(
seq,
state
.seq_to_step
.get(&parent_seq)
.cloned()
.unwrap_or_default(),
);
StepResult::Skip
};
save_session(session_path, &path_doc, &state)?;
Ok(result)
}
fn run_step(
session_path: PathBuf,
seq: u64,
parent_seq: u64,
actor_override: Option<String>,
time_override: Option<String>,
source: Option<String>,
) -> Result<()> {
let content = read_stdin()?;
match record_step(
&session_path,
content,
seq,
parent_seq,
actor_override,
time_override,
source.as_deref(),
)? {
StepResult::Created(id) => println!("{id}"),
StepResult::Skip => println!("skip"),
}
Ok(())
}
fn run_visit(session_path: PathBuf, seq: u64, inherit_from: Option<u64>) -> Result<()> {
let content = read_stdin()?;
let (path_doc, mut state) = load_session(&session_path)?;
let mut changed = false;
use std::collections::hash_map::Entry;
if let Entry::Vacant(e) = state.buffer_cache.entry(seq) {
e.insert(content);
changed = true;
}
#[allow(clippy::map_entry)]
if !state.seq_to_step.contains_key(&seq)
&& let Some(ancestor) = inherit_from
{
let step_id = state
.seq_to_step
.get(&ancestor)
.cloned()
.unwrap_or_default();
state.seq_to_step.insert(seq, step_id);
changed = true;
}
if changed {
save_session(&session_path, &path_doc, &state)?;
}
Ok(())
}
fn run_note(session_path: PathBuf, intent: String) -> Result<()> {
let (mut path_doc, state) = load_session(&session_path)?;
if path_doc.path.head == "none" {
anyhow::bail!("no head step to annotate");
}
let head_id = path_doc.path.head.clone();
let step = path_doc
.steps
.iter_mut()
.find(|s| s.step.id == head_id)
.context("head step not found in session")?;
step.meta.get_or_insert_with(v1::StepMeta::default).intent = Some(intent);
save_session(&session_path, &path_doc, &state)?;
Ok(())
}
fn run_annotate(
session_path: PathBuf,
step_id: Option<String>,
intent: Option<String>,
source_json: Option<String>,
ref_jsons: Vec<String>,
) -> Result<()> {
let (mut path_doc, state) = load_session(&session_path)?;
let target_id = match step_id {
Some(id) => id,
None => {
if path_doc.path.head == "none" {
anyhow::bail!("no head step to annotate (use --step to specify)");
}
path_doc.path.head.clone()
}
};
let step = path_doc
.steps
.iter_mut()
.find(|s| s.step.id == target_id)
.with_context(|| format!("step not found: {target_id}"))?;
let meta = step.meta.get_or_insert_with(v1::StepMeta::default);
if let Some(text) = intent {
meta.intent = Some(text);
}
if let Some(src) = source_json {
let vcs_source: v1::VcsSource =
serde_json::from_str(&src).context("failed to parse --source JSON as VcsSource")?;
meta.source = Some(vcs_source);
}
for ref_json in &ref_jsons {
let r: v1::Ref =
serde_json::from_str(ref_json).context("failed to parse --ref JSON as Ref")?;
meta.refs.push(r);
}
save_session(&session_path, &path_doc, &state)?;
Ok(())
}
fn run_export(session_path: PathBuf, pretty: bool) -> Result<()> {
let (path_doc, _state) = load_session(&session_path)?;
let doc = v1::Document::Path(path_doc);
let json = format_output(doc, pretty)?;
println!("{json}");
Ok(())
}
fn run_close(session_path: PathBuf, pretty: bool, output: Option<PathBuf>) -> Result<()> {
let (path_doc, _state) = load_session(&session_path)?;
let doc = v1::Document::Path(path_doc);
let json = format_output(doc, pretty)?;
if let Some(out) = output {
std::fs::write(&out, &json)
.with_context(|| format!("failed to write to {}", out.display()))?;
} else {
println!("{json}");
}
std::fs::remove_file(&session_path)
.with_context(|| format!("failed to remove session file: {}", session_path.display()))?;
Ok(())
}
fn run_list(session_dir_opt: Option<PathBuf>, json: bool) -> Result<()> {
let dir = session_dir(session_dir_opt.as_ref());
let mut sessions: Vec<SessionSummary> = Vec::new();
let entries = std::fs::read_dir(&dir)
.with_context(|| format!("failed to read directory: {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("track-")
&& name_str.ends_with(".json")
&& let Ok((path_doc, state)) = load_session(&entry.path())
{
sessions.push(SessionSummary {
session_file: entry.path().to_string_lossy().to_string(),
session_id: path_doc.path.id,
file: state.file,
actor: state.default_actor,
steps: path_doc.steps.len(),
created_at: state.created_at,
});
}
}
if json {
let out =
serde_json::to_string_pretty(&sessions).context("failed to serialize session list")?;
println!("{out}");
} else if sessions.is_empty() {
println!("No active tracking sessions.");
} else {
for s in &sessions {
println!(
"{} | {} | {} | {} steps | {}",
s.session_id, s.file, s.actor, s.steps, s.created_at,
);
}
}
Ok(())
}
#[derive(Debug, Serialize)]
struct SessionSummary {
session_file: String,
session_id: String,
file: String,
actor: String,
steps: usize,
created_at: String,
}
pub fn run(op: TrackOp, pretty: bool) -> Result<()> {
match op {
ref init @ TrackOp::Init { .. } => run_init(init),
TrackOp::Step {
session,
seq,
parent_seq,
actor,
time,
source,
} => run_step(session, seq, parent_seq, actor, time, source),
TrackOp::Visit {
session,
seq,
inherit_from,
} => run_visit(session, seq, inherit_from),
TrackOp::Note { session, intent } => run_note(session, intent),
TrackOp::Annotate {
session,
step,
intent,
source,
refs,
} => run_annotate(session, step, intent, source, refs),
TrackOp::Export { session } => run_export(session, pretty),
TrackOp::Close { session, output } => run_close(session, pretty, output),
TrackOp::List { session_dir, json } => run_list(session_dir, json),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_session(dir: &std::path::Path, content: &str) -> PathBuf {
let path_doc = v1::Path::new("track-test-1", None, "none");
let state = TrackState {
version: 1,
file: "test.txt".to_string(),
default_actor: "human:tester".to_string(),
buffer_cache: {
let mut m = HashMap::new();
m.insert(0, content.to_string());
m
},
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
let path = dir.join("track-test-1.json");
save_session(&path, &path_doc, &state).unwrap();
path
}
#[test]
fn test_compute_diff_identical() {
assert!(compute_diff("hello\n", "hello\n").is_none());
}
#[test]
fn test_compute_diff_changed() {
let diff = compute_diff("hello\n", "world\n");
assert!(diff.is_some());
let d = diff.unwrap();
assert!(d.contains("-hello"));
assert!(d.contains("+world"));
}
#[test]
fn test_compute_diff_empty_strings() {
assert!(compute_diff("", "").is_none());
}
#[test]
fn test_compute_diff_from_empty() {
let diff = compute_diff("", "new content\n").unwrap();
assert!(diff.contains("+new content"));
}
#[test]
fn test_compute_diff_to_empty() {
let diff = compute_diff("old content\n", "").unwrap();
assert!(diff.contains("-old content"));
}
#[test]
fn test_multiline_diff() {
let old = "line one\nline two\nline three\n";
let new = "line one\nline TWO\nline three\nline four\n";
let diff = compute_diff(old, new).unwrap();
assert!(diff.contains("-line two"));
assert!(diff.contains("+line TWO"));
assert!(diff.contains("+line four"));
}
#[test]
fn test_now_iso8601_format() {
let ts = now_iso8601();
assert!(ts.ends_with('Z'));
assert!(ts.contains('T'));
assert_eq!(ts.len(), 20);
}
#[test]
fn test_session_dir_explicit() {
let p = PathBuf::from("/custom/dir");
assert_eq!(session_dir(Some(&p)), PathBuf::from("/custom/dir"));
}
#[test]
fn test_session_dir_default() {
let d = session_dir(None);
assert_eq!(d, std::env::temp_dir());
}
#[test]
fn test_format_output_pretty_and_compact() {
let step = v1::Step::new("s1", "human:alex", "2026-01-01T00:00:00Z");
let doc = v1::Document::Step(step);
let pretty = format_output(doc.clone(), true).unwrap();
assert!(pretty.contains('\n'));
let compact = format_output(doc, false).unwrap();
assert!(!compact.contains('\n'));
}
#[test]
fn test_save_and_load_session() {
let dir = TempDir::new().unwrap();
let path = make_session(dir.path(), "initial content\n");
let (path_doc, state) = load_session(&path).unwrap();
assert_eq!(path_doc.path.id, "track-test-1");
assert_eq!(state.file, "test.txt");
assert_eq!(state.buffer_cache[&0], "initial content\n");
}
#[test]
fn test_load_session_nonexistent() {
let result = load_session(std::path::Path::new("/nonexistent/session.json"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("failed to read session file")
);
}
#[test]
fn test_load_session_corrupt_json() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not valid json {{{").unwrap();
let result = load_session(&path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("failed to parse session file")
);
}
#[test]
fn test_session_with_base() {
let dir = TempDir::new().unwrap();
let base = v1::Base {
uri: "github:org/repo".to_string(),
ref_str: Some("main".to_string()),
};
let path_doc = v1::Path::new("track-base-test", Some(base), "none");
let state = TrackState {
version: 1,
file: "f.rs".to_string(),
default_actor: "human:dev".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
let path = dir.path().join("track-base-test.json");
save_session(&path, &path_doc, &state).unwrap();
let (loaded_doc, _) = load_session(&path).unwrap();
let base = loaded_doc.path.base.unwrap();
assert_eq!(base.uri, "github:org/repo");
assert_eq!(base.ref_str, Some("main".to_string()));
}
#[test]
fn test_session_without_base() {
let dir = TempDir::new().unwrap();
let path_doc = v1::Path::new("track-no-base", None, "none");
let state = TrackState {
version: 1,
file: "f.rs".to_string(),
default_actor: "human:dev".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
let path = dir.path().join("track-no-base.json");
save_session(&path, &path_doc, &state).unwrap();
let (loaded_doc, _) = load_session(&path).unwrap();
assert!(loaded_doc.path.base.is_none());
}
#[test]
fn test_session_file_is_valid_toolpath_document() {
let dir = TempDir::new().unwrap();
let path = make_session(dir.path(), "hello\n");
let data = std::fs::read_to_string(&path).unwrap();
let doc = v1::Document::from_json(&data).unwrap();
match doc {
v1::Document::Path(p) => {
assert_eq!(p.path.id, "track-test-1");
assert!(p.meta.as_ref().unwrap().extra.contains_key("track"));
}
_ => panic!("Expected Path"),
}
}
fn simple_init(dir: &std::path::Path, content: &str, file: &str, actor: &str) -> PathBuf {
init_session(InitConfig {
content: content.to_string(),
file: file.to_string(),
actor: actor.to_string(),
actors: None,
title: None,
source: None,
base_uri: None,
base_ref: None,
session_dir: Some(dir.to_path_buf()),
})
.unwrap()
}
#[test]
fn test_init_creates_session_file() {
let dir = TempDir::new().unwrap();
let session_path = simple_init(dir.path(), "hello\n", "test.txt", "human:alex");
assert!(session_path.exists());
let (path_doc, state) = load_session(&session_path).unwrap();
assert!(path_doc.path.id.starts_with("track-"));
assert_eq!(state.file, "test.txt");
assert_eq!(state.default_actor, "human:alex");
assert_eq!(state.buffer_cache[&0], "hello\n");
assert_eq!(state.version, 1);
assert_eq!(path_doc.path.head, "none");
assert!(path_doc.steps.is_empty());
assert_eq!(state.step_counter, 0);
}
#[test]
fn test_init_with_base() {
let dir = TempDir::new().unwrap();
let session_path = init_session(InitConfig {
content: "content".to_string(),
file: "f.rs".to_string(),
actor: "human:dev".to_string(),
actors: None,
title: None,
source: None,
base_uri: Some("github:org/repo".to_string()),
base_ref: Some("abc123".to_string()),
session_dir: Some(dir.path().to_path_buf()),
})
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let base = path_doc.path.base.unwrap();
assert_eq!(base.uri, "github:org/repo");
assert_eq!(base.ref_str, Some("abc123".to_string()));
}
#[test]
fn test_init_session_roundtrips() {
let dir = TempDir::new().unwrap();
let session_path = simple_init(dir.path(), "initial\n", "test.txt", "human:alex");
let (path_doc, state) = load_session(&session_path).unwrap();
assert!(path_doc.path.id.starts_with("track-"));
assert_eq!(state.buffer_cache[&0], "initial\n");
}
#[test]
fn test_init_with_actor_def() {
let dir = TempDir::new().unwrap();
let mut actors = HashMap::new();
actors.insert(
"human:alex".to_string(),
v1::ActorDefinition {
name: Some("Alex".to_string()),
identities: vec![v1::Identity {
system: "email".to_string(),
id: "alex@example.com".to_string(),
}],
..Default::default()
},
);
let session_path = init_session(InitConfig {
content: "content\n".to_string(),
file: "f.rs".to_string(),
actor: "human:alex".to_string(),
actors: Some(actors),
title: None,
source: None,
base_uri: None,
base_ref: None,
session_dir: Some(dir.path().to_path_buf()),
})
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let meta = path_doc.meta.as_ref().unwrap();
let actors = meta.actors.as_ref().unwrap();
let def = &actors["human:alex"];
assert_eq!(def.name.as_deref(), Some("Alex"));
assert_eq!(def.identities[0].id, "alex@example.com");
let doc = v1::Document::Path(path_doc);
let json = doc.to_json_pretty().unwrap();
assert!(json.contains("alex@example.com"));
let parsed = v1::Document::from_json(&json).unwrap();
match parsed {
v1::Document::Path(p) => {
let a = p.meta.unwrap().actors.unwrap();
assert_eq!(a["human:alex"].name.as_deref(), Some("Alex"));
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_record_step_creates_root_step() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(result, StepResult::Created("step-001".to_string()));
let (path_doc, state) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 1);
assert_eq!(path_doc.steps[0].step.id, "step-001");
assert!(path_doc.steps[0].step.parents.is_empty());
assert_eq!(path_doc.steps[0].step.actor, "human:tester");
assert_eq!(path_doc.path.head, "step-001");
assert_eq!(state.buffer_cache[&1], "world\n");
assert_eq!(state.seq_to_step[&1], "step-001");
let change = &path_doc.steps[0].change["test.txt"];
let raw = change.raw.as_ref().unwrap();
assert!(raw.contains("-hello"));
assert!(raw.contains("+world"));
}
#[test]
fn test_record_step_skip_on_identical() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result =
record_step(&session_path, "hello\n".to_string(), 1, 0, None, None, None).unwrap();
assert_eq!(result, StepResult::Skip);
let (path_doc, state) = load_session(&session_path).unwrap();
assert!(path_doc.steps.is_empty());
assert_eq!(path_doc.path.head, "none");
assert_eq!(state.buffer_cache[&1], "hello\n");
}
#[test]
fn test_record_step_with_parent() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "original\n");
let r1 = record_step(
&session_path,
"edit-1\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r1, StepResult::Created("step-001".to_string()));
let r2 = record_step(
&session_path,
"edit-2\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r2, StepResult::Created("step-002".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 2);
assert!(path_doc.steps[0].step.parents.is_empty()); assert_eq!(path_doc.steps[1].step.parents, vec!["step-001"]); assert_eq!(path_doc.path.head, "step-002");
}
#[test]
fn test_record_step_actor_override() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
Some("tool:formatter".to_string()),
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps[0].step.actor, "tool:formatter");
}
#[test]
fn test_record_step_time_override() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-06-15T12:00:00Z".to_string()),
None,
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps[0].step.timestamp, "2026-06-15T12:00:00Z");
}
#[test]
fn test_record_step_missing_parent_seq() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = record_step(
&session_path,
"world\n".to_string(),
1,
99, None,
None,
None,
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("parent seq 99 not found")
);
}
#[test]
fn test_record_step_branching_dag() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "original\n");
record_step(
&session_path,
"branch-a\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"branch-b\n".to_string(),
2,
0,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 2);
assert!(path_doc.steps[0].step.parents.is_empty());
assert!(path_doc.steps[1].step.parents.is_empty());
assert_eq!(path_doc.path.head, "step-002");
}
#[test]
fn test_record_step_branching_with_dead_ends() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "original\n");
record_step(
&session_path,
"edit-1\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"edit-2\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"branch-b\n".to_string(),
3,
1,
None,
Some("2026-01-01T00:03:00Z".to_string()),
None,
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 3);
assert_eq!(path_doc.steps[1].step.parents, vec!["step-001"]);
assert_eq!(path_doc.steps[2].step.parents, vec!["step-001"]);
let dead = v1::query::dead_ends(&path_doc.steps, &path_doc.path.head);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].step.id, "step-002");
}
#[test]
fn test_record_step_skip_preserves_seq_mapping() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
let r = record_step(&session_path, "hello\n".to_string(), 2, 0, None, None, None).unwrap();
assert_eq!(r, StepResult::Skip);
let r3 = record_step(
&session_path,
"goodbye\n".to_string(),
3,
2,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r3, StepResult::Created("step-002".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 2);
assert!(path_doc.steps[1].step.parents.is_empty());
}
#[test]
fn test_record_step_revisit_seq_skips_without_mutation() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"C\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
let (before_doc, before_state) = load_session(&session_path).unwrap();
assert_eq!(before_doc.steps.len(), 2);
assert_eq!(before_state.seq_to_step[&1], "step-001");
assert_eq!(before_doc.path.head, "step-002");
let r = record_step(&session_path, "B\n".to_string(), 1, 2, None, None, None).unwrap();
assert_eq!(r, StepResult::Skip);
let (after_doc, after_state) = load_session(&session_path).unwrap();
assert_eq!(after_doc.steps.len(), 2); assert_eq!(after_state.seq_to_step[&1], "step-001"); assert_eq!(after_doc.path.head, "step-002"); }
#[test]
fn test_record_step_undo_then_branch() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"C\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
let r = record_step(&session_path, "B\n".to_string(), 1, 2, None, None, None).unwrap();
assert_eq!(r, StepResult::Skip);
let r3 = record_step(
&session_path,
"D\n".to_string(),
3,
1,
None,
Some("2026-01-01T00:03:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r3, StepResult::Created("step-003".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps.len(), 3);
assert_eq!(path_doc.steps[2].step.parents, vec!["step-001"]);
assert_eq!(path_doc.path.head, "step-003");
}
#[test]
fn test_record_step_undo_to_initial_then_branch() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
let r = record_step(&session_path, "A\n".to_string(), 0, 1, None, None, None).unwrap();
assert_eq!(r, StepResult::Skip);
let r2 = record_step(
&session_path,
"C\n".to_string(),
2,
0,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r2, StepResult::Created("step-002".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert!(path_doc.steps[1].step.parents.is_empty());
}
fn simulate_visit(
session_path: &std::path::Path,
seq: u64,
content: &str,
inherit_from: Option<u64>,
) {
let (path_doc, mut state) = load_session(session_path).unwrap();
state
.buffer_cache
.entry(seq)
.or_insert_with(|| content.to_string());
if !state.seq_to_step.contains_key(&seq)
&& let Some(ancestor) = inherit_from
{
let step_id = state
.seq_to_step
.get(&ancestor)
.cloned()
.unwrap_or_default();
state.seq_to_step.insert(seq, step_id);
}
save_session(session_path, &path_doc, &state).unwrap();
}
#[test]
fn test_visit_caches_content_and_inherits_mapping() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"C\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
simulate_visit(&session_path, 1, "B\n", Some(1));
let (_, state) = load_session(&session_path).unwrap();
assert_eq!(state.seq_to_step[&1], "step-001");
}
#[test]
fn test_visit_intermediate_inherits_ancestor_step() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
{
let (path_doc, mut state) = load_session(&session_path).unwrap();
state.buffer_cache.insert(3, "C\n".to_string());
save_session(&session_path, &path_doc, &state).unwrap();
}
record_step(
&session_path,
"D\n".to_string(),
4,
3,
None,
Some("2026-01-01T00:03:00Z".to_string()),
None,
)
.unwrap();
simulate_visit(&session_path, 2, "B-mid\n", Some(1));
let (_, state) = load_session(&session_path).unwrap();
assert_eq!(state.buffer_cache[&2], "B-mid\n");
assert_eq!(state.seq_to_step[&2], "step-001");
let r = record_step(
&session_path,
"E\n".to_string(),
5,
2,
None,
Some("2026-01-01T00:04:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r, StepResult::Created("step-003".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert_eq!(path_doc.steps[2].step.parents, vec!["step-001"]);
}
#[test]
fn test_visit_inherit_from_initial_state() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
simulate_visit(&session_path, 3, "X\n", Some(0));
let (_, state) = load_session(&session_path).unwrap();
assert_eq!(state.seq_to_step[&3], "");
let r = record_step(
&session_path,
"Y\n".to_string(),
4,
3,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r, StepResult::Created("step-001".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
assert!(path_doc.steps[0].step.parents.is_empty());
}
#[test]
fn test_visit_does_not_overwrite_existing_mapping() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "A\n");
record_step(
&session_path,
"B\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
simulate_visit(&session_path, 1, "B\n", Some(0));
let (_, state) = load_session(&session_path).unwrap();
assert_eq!(state.seq_to_step[&1], "step-001"); }
#[test]
fn test_note_sets_intent() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_note(session_path.clone(), "Fix the greeting".to_string()).unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let intent = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.intent.as_ref());
assert_eq!(intent, Some(&"Fix the greeting".to_string()));
}
#[test]
fn test_note_overwrites_previous_intent() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_note(session_path.clone(), "First intent".to_string()).unwrap();
run_note(session_path.clone(), "Updated intent".to_string()).unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let intent = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.intent.as_ref());
assert_eq!(intent, Some(&"Updated intent".to_string()));
}
#[test]
fn test_note_no_head_step_errors() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = run_note(session_path, "some intent".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no head step"));
}
#[test]
fn test_close_deletes_session_file() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
assert!(session_path.exists());
run_close(session_path.clone(), false, None).unwrap();
assert!(!session_path.exists());
}
#[test]
fn test_close_writes_to_output_file() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
let output_path = dir.path().join("output.json");
run_close(session_path.clone(), true, Some(output_path.clone())).unwrap();
assert!(!session_path.exists());
let json = std::fs::read_to_string(&output_path).unwrap();
assert!(!json.contains("\"track\""));
assert!(!json.contains("buffer_cache"));
let doc = v1::Document::from_json(&json).unwrap();
match doc {
v1::Document::Path(p) => {
assert_eq!(p.path.id, "track-test-1");
assert_eq!(p.path.head, "step-001");
assert_eq!(p.steps.len(), 1);
assert!(p.meta.is_none());
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_close_nonexistent_session_errors() {
let result = run_close(PathBuf::from("/nonexistent/session.json"), false, None);
assert!(result.is_err());
}
#[test]
fn test_list_finds_sessions() {
let dir = TempDir::new().unwrap();
let path_doc_1 = v1::Path::new("track-20260101T000000-111", None, "none");
let state_1 = TrackState {
version: 1,
file: "a.txt".to_string(),
default_actor: "human:alice".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
save_session(
&dir.path().join("track-20260101T000000-111.json"),
&path_doc_1,
&state_1,
)
.unwrap();
let mut path_doc_2 = v1::Path::new("track-20260101T000000-222", None, "step-001");
path_doc_2.steps.push(v1::Step::new(
"step-001",
"human:bob",
"2026-01-01T00:01:00Z",
));
let state_2 = TrackState {
version: 1,
file: "b.txt".to_string(),
default_actor: "human:bob".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 1,
created_at: "2026-01-01T00:00:01Z".to_string(),
};
save_session(
&dir.path().join("track-20260101T000000-222.json"),
&path_doc_2,
&state_2,
)
.unwrap();
std::fs::write(dir.path().join("other.json"), "{}").unwrap();
run_list(Some(dir.path().to_path_buf()), false).unwrap();
run_list(Some(dir.path().to_path_buf()), true).unwrap();
}
#[test]
fn test_list_empty_directory() {
let dir = TempDir::new().unwrap();
run_list(Some(dir.path().to_path_buf()), false).unwrap();
}
#[test]
fn test_list_nonexistent_directory() {
let result = run_list(Some(PathBuf::from("/nonexistent/dir")), false);
assert!(result.is_err());
}
#[test]
fn test_list_ignores_corrupt_sessions() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("track-corrupt.json"), "not json").unwrap();
run_list(Some(dir.path().to_path_buf()), false).unwrap();
}
#[test]
fn test_session_with_base_is_valid_document() {
let dir = TempDir::new().unwrap();
let base = v1::Base {
uri: "github:org/repo".to_string(),
ref_str: Some("abc123".to_string()),
};
let mut path_doc = v1::Path::new("track-test-doc", Some(base), "step-001");
path_doc.steps.push(
v1::Step::new("step-001", "human:alex", "2026-01-01T00:01:00Z")
.with_raw_change("src/main.rs", "@@ changed"),
);
let state = TrackState {
version: 1,
file: "src/main.rs".to_string(),
default_actor: "human:alex".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 1,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
let session_path = dir.path().join("track-test-doc.json");
save_session(&session_path, &path_doc, &state).unwrap();
let data = std::fs::read_to_string(&session_path).unwrap();
let doc = v1::Document::from_json(&data).unwrap();
match &doc {
v1::Document::Path(p) => {
assert_eq!(p.path.id, "track-test-doc");
assert_eq!(p.path.head, "step-001");
assert_eq!(p.path.base.as_ref().unwrap().uri, "github:org/repo");
assert_eq!(p.steps.len(), 1);
}
_ => panic!("Expected Path"),
}
let (exported, _) = load_session(&session_path).unwrap();
assert!(exported.meta.is_none()); assert_eq!(exported.path.id, "track-test-doc");
assert_eq!(exported.steps.len(), 1);
let doc = v1::Document::Path(exported);
let json = doc.to_json_pretty().unwrap();
let parsed = v1::Document::from_json(&json).unwrap();
match parsed {
v1::Document::Path(p) => assert_eq!(p.path.id, "track-test-doc"),
_ => panic!("Expected Path"),
}
}
#[test]
fn test_session_no_base_exports_correctly() {
let dir = TempDir::new().unwrap();
let path_doc = v1::Path::new("track-no-base", None, "none");
let state = TrackState {
version: 1,
file: "f.rs".to_string(),
default_actor: "human:dev".to_string(),
buffer_cache: HashMap::new(),
seq_to_step: HashMap::new(),
step_counter: 0,
created_at: "2026-01-01T00:00:00Z".to_string(),
};
let session_path = dir.path().join("track-no-base.json");
save_session(&session_path, &path_doc, &state).unwrap();
let (exported, _) = load_session(&session_path).unwrap();
assert!(exported.path.base.is_none());
assert_eq!(exported.path.head, "none");
assert!(exported.steps.is_empty());
}
#[test]
fn test_multi_step_session_roundtrips_after_export() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "original\n");
record_step(
&session_path,
"edit-1\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"edit-2\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"branch\n".to_string(),
3,
1,
None,
Some("2026-01-01T00:03:00Z".to_string()),
None,
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let doc = v1::Document::Path(path_doc.clone());
let json = doc.to_json_pretty().unwrap();
let parsed = v1::Document::from_json(&json).unwrap();
match parsed {
v1::Document::Path(p) => {
assert_eq!(p.steps.len(), 3);
assert_eq!(p.path.head, "step-003");
assert!(p.steps[0].step.parents.is_empty());
assert_eq!(p.steps[1].step.parents, vec!["step-001"]);
assert_eq!(p.steps[2].step.parents, vec!["step-001"]);
}
_ => panic!("Expected Path"),
}
let dead = v1::query::dead_ends(&path_doc.steps, &path_doc.path.head);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].step.id, "step-002");
}
#[test]
fn test_full_editing_session_flow() {
let dir = TempDir::new().unwrap();
let init_path = init_session(InitConfig {
content: "line 1\nline 2\nline 3\n".to_string(),
file: "src/lib.rs".to_string(),
actor: "human:alex".to_string(),
actors: None,
title: None,
source: None,
base_uri: Some("github:org/repo".to_string()),
base_ref: Some("main".to_string()),
session_dir: Some(dir.path().to_path_buf()),
})
.unwrap();
let r1 = record_step(
&init_path,
"line 1\nline 2 modified\nline 3\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r1, StepResult::Created("step-001".to_string()));
let r2 = record_step(
&init_path,
"line 1\nline 2 modified\nline 3\nline 4\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r2, StepResult::Created("step-002".to_string()));
run_note(init_path.clone(), "Add line 4".to_string()).unwrap();
let r3 = record_step(
&init_path,
"line 1\nline 2 modified\nline 3\nline FOUR\n".to_string(),
3,
1,
None,
Some("2026-01-01T00:03:00Z".to_string()),
None,
)
.unwrap();
assert_eq!(r3, StepResult::Created("step-003".to_string()));
let data = std::fs::read_to_string(&init_path).unwrap();
let mid_doc = v1::Document::from_json(&data).unwrap();
match &mid_doc {
v1::Document::Path(p) => {
assert_eq!(p.steps.len(), 3);
assert_eq!(p.path.head, "step-003");
let dead = v1::query::dead_ends(&p.steps, &p.path.head);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].step.id, "step-002");
}
_ => panic!("Expected Path"),
}
let output = dir.path().join("result.json");
run_close(init_path.clone(), true, Some(output.clone())).unwrap();
assert!(!init_path.exists());
let json = std::fs::read_to_string(&output).unwrap();
assert!(!json.contains("buffer_cache"));
assert!(!json.contains("seq_to_step"));
let doc = v1::Document::from_json(&json).unwrap();
match doc {
v1::Document::Path(p) => {
assert_eq!(p.steps.len(), 3);
assert_eq!(p.path.head, "step-003");
assert!(p.path.base.is_some());
assert!(p.steps[0].step.parents.is_empty());
assert_eq!(p.steps[1].step.parents, vec!["step-001"]);
assert_eq!(p.steps[2].step.parents, vec!["step-001"]);
let intent = p.steps[1].meta.as_ref().and_then(|m| m.intent.as_ref());
assert_eq!(intent, Some(&"Add line 4".to_string()));
let dead = v1::query::dead_ends(&p.steps, &p.path.head);
assert_eq!(dead.len(), 1);
assert_eq!(dead[0].step.id, "step-002");
assert!(p.meta.is_none());
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_record_step_with_source() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
Some(r#"{"type":"git","revision":"abc123"}"#),
)
.unwrap();
assert_eq!(result, StepResult::Created("step-001".to_string()));
let (path_doc, _) = load_session(&session_path).unwrap();
let source = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.source.as_ref())
.unwrap();
assert_eq!(source.vcs_type, "git");
assert_eq!(source.revision, "abc123");
}
#[test]
fn test_record_step_source_bad_json() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
Some("not valid json"),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("failed to parse --source JSON")
);
}
#[test]
fn test_record_step_source_with_extra_fields() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
Some(r#"{"type":"git","revision":"abc123","branch":"main","dirty":true}"#),
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let source = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.source.as_ref())
.unwrap();
assert_eq!(source.vcs_type, "git");
assert_eq!(source.revision, "abc123");
assert_eq!(source.extra["branch"], serde_json::json!("main"));
assert_eq!(source.extra["dirty"], serde_json::json!(true));
let doc = v1::Document::Path(path_doc);
let json = doc.to_json_pretty().unwrap();
let parsed = v1::Document::from_json(&json).unwrap();
match parsed {
v1::Document::Path(p) => {
let s = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap();
assert_eq!(s.extra["branch"], serde_json::json!("main"));
assert_eq!(s.extra["dirty"], serde_json::json!(true));
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_source_survives_export() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
Some(r#"{"type":"git","revision":"def456","change_id":"I1234"}"#),
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let doc = v1::Document::Path(path_doc);
let json = doc.to_json_pretty().unwrap();
let parsed = v1::Document::from_json(&json).unwrap();
match parsed {
v1::Document::Path(p) => {
let source = p.steps[0].meta.as_ref().unwrap().source.as_ref().unwrap();
assert_eq!(source.vcs_type, "git");
assert_eq!(source.revision, "def456");
assert_eq!(source.change_id.as_deref(), Some("I1234"));
}
_ => panic!("Expected Path"),
}
}
#[test]
fn test_annotate_intent_on_head() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_annotate(
session_path.clone(),
None, Some("Fix greeting".to_string()),
None,
vec![],
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let intent = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.intent.as_ref());
assert_eq!(intent, Some(&"Fix greeting".to_string()));
}
#[test]
fn test_annotate_intent_on_specific_step() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
record_step(
&session_path,
"again\n".to_string(),
2,
1,
None,
Some("2026-01-01T00:02:00Z".to_string()),
None,
)
.unwrap();
run_annotate(
session_path.clone(),
Some("step-001".to_string()),
Some("First edit".to_string()),
None,
vec![],
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let intent = path_doc.steps[0]
.meta
.as_ref()
.and_then(|m| m.intent.as_ref());
assert_eq!(intent, Some(&"First edit".to_string()));
assert!(
path_doc.steps[1]
.meta
.as_ref()
.and_then(|m| m.intent.as_ref())
.is_none()
);
}
#[test]
fn test_annotate_source() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_annotate(
session_path.clone(),
None,
None,
Some(r#"{"type":"git","revision":"abc123"}"#.to_string()),
vec![],
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let source = path_doc.steps[0]
.meta
.as_ref()
.unwrap()
.source
.as_ref()
.unwrap();
assert_eq!(source.vcs_type, "git");
assert_eq!(source.revision, "abc123");
}
#[test]
fn test_annotate_ref() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_annotate(
session_path.clone(),
None,
None,
None,
vec![r#"{"rel":"issue","href":"https://github.com/org/repo/issues/1"}"#.to_string()],
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let refs = &path_doc.steps[0].meta.as_ref().unwrap().refs;
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].rel, "issue");
assert!(refs[0].href.contains("issues/1"));
}
#[test]
fn test_annotate_multiple_refs() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
run_annotate(
session_path.clone(),
None,
None,
None,
vec![r#"{"rel":"issue","href":"https://example.com/1"}"#.to_string()],
)
.unwrap();
run_annotate(
session_path.clone(),
None,
None,
None,
vec![r#"{"rel":"pr","href":"https://example.com/pr/42"}"#.to_string()],
)
.unwrap();
let (path_doc, _) = load_session(&session_path).unwrap();
let refs = &path_doc.steps[0].meta.as_ref().unwrap().refs;
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].rel, "issue");
assert_eq!(refs[1].rel, "pr");
}
#[test]
fn test_annotate_no_step_errors() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
let result = run_annotate(
session_path,
None, Some("intent".to_string()),
None,
vec![],
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no head step"));
}
#[test]
fn test_annotate_missing_step_errors() {
let dir = TempDir::new().unwrap();
let session_path = make_session(dir.path(), "hello\n");
record_step(
&session_path,
"world\n".to_string(),
1,
0,
None,
Some("2026-01-01T00:01:00Z".to_string()),
None,
)
.unwrap();
let result = run_annotate(
session_path,
Some("nonexistent-step".to_string()),
Some("intent".to_string()),
None,
vec![],
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("step not found: nonexistent-step")
);
}
}