use std::collections::HashSet;
use std::path::Path;
use chrono;
use serde::Serialize;
use crate::model::project::Project;
use crate::model::task::{Metadata, Task, TaskState};
use crate::model::track::{Track, TrackNode};
#[derive(Debug, Default, Serialize)]
pub struct CheckResult {
pub valid: bool,
pub errors: Vec<CheckError>,
pub warnings: Vec<CheckWarning>,
pub info: Vec<CheckInfo>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CheckError {
#[serde(rename = "dangling_dep")]
DanglingDep {
track_id: String,
task_id: String,
dep_id: String,
},
#[serde(rename = "broken_ref")]
BrokenRef {
track_id: String,
task_id: String,
path: String,
},
#[serde(rename = "broken_spec")]
BrokenSpec {
track_id: String,
task_id: String,
path: String,
},
#[serde(rename = "duplicate_id")]
DuplicateId {
task_id: String,
track_ids: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CheckWarning {
#[serde(rename = "missing_id")]
MissingId { track_id: String, title: String },
#[serde(rename = "missing_added_date")]
MissingAddedDate { track_id: String, task_id: String },
#[serde(rename = "missing_resolved_date")]
MissingResolvedDate { track_id: String, task_id: String },
#[serde(rename = "task_in_wrong_section")]
TaskInWrongSection {
track_id: String,
task_id: String,
expected: crate::model::track::SectionKind,
actual: crate::model::track::SectionKind,
},
#[serde(rename = "lost_task")]
LostTask { track_id: String, task_id: String },
#[serde(rename = "child_id_not_under_parent")]
ChildIdNotUnderParent {
track_id: String,
task_id: String,
parent_id: String,
},
#[serde(rename = "actor_token_unregistered")]
ActorTokenUnregistered { token: String },
#[serde(rename = "actor_token_retired")]
ActorTokenRetiredButHeld { token: String },
#[serde(rename = "actor_name_collision")]
ActorNameCollision { name: String, tokens: Vec<String> },
#[serde(rename = "local_file_committed")]
LocalFileCommitted { path: String, tracked: bool },
#[serde(rename = "unclosed_note_fence")]
UnclosedNoteFence {
track_id: String,
task_id: Option<String>,
title: String,
fence: String,
},
#[serde(rename = "stranded_line")]
StrandedLine {
track_id: String,
before_task_id: Option<String>,
before_title: String,
line: String,
},
#[serde(rename = "id_reissued_after_archive")]
IdReissuedAfterArchive {
task_id: String,
tracks: Vec<String>,
archives: Vec<String>,
},
#[serde(rename = "duplicate_archived_id")]
DuplicateArchivedId {
task_id: String,
total: usize,
archives: Vec<String>,
},
#[serde(rename = "id_frontier_unreadable")]
IdFrontierUnreadable { path: String, detail: String },
#[serde(rename = "id_frontier_was_reset")]
IdFrontierWasReset { path: String },
#[serde(rename = "unclosed_inbox_fence")]
UnclosedInboxFence {
index: usize,
title: String,
fence: String,
},
#[serde(rename = "interrupted_operation")]
InterruptedOperation {
operation: String,
command: String,
started: String,
},
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CheckInfo {
#[serde(rename = "recovery_log")]
RecoveryLog { entry_count: usize, oldest: String },
}
pub fn check_project(project: &Project) -> CheckResult {
let mut result = CheckResult::default();
let all_ids = collect_all_task_ids(project);
let duplicates = find_duplicate_ids(project);
for (task_id, track_ids) in &duplicates {
result.errors.push(CheckError::DuplicateId {
task_id: task_id.clone(),
track_ids: track_ids.clone(),
});
}
for (track_id, track) in &project.tracks {
check_track(track, track_id, &all_ids, &project.root, &mut result);
}
check_actor_registry(&project.frame_dir, &mut result);
check_actor_name_collisions(&project.frame_dir, &mut result);
check_local_files_ignored(&project.frame_dir, &mut result);
check_archived_id_collisions(project, &mut result);
check_id_frontier(&project.frame_dir, &mut result);
check_inflight(&project.frame_dir, &mut result);
if let Some(ref inbox) = project.inbox {
check_inbox(inbox, &mut result);
}
if let Some(summary) = crate::io::recovery::recovery_summary(&project.frame_dir) {
let oldest_str = summary
.oldest
.map(|ts| ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default();
result.info.push(CheckInfo::RecoveryLog {
entry_count: summary.entry_count,
oldest: oldest_str,
});
}
result.valid = result.errors.is_empty();
result
}
fn check_track(
track: &Track,
track_id: &str,
all_ids: &HashSet<String>,
project_root: &Path,
result: &mut CheckResult,
) {
for node in &track.nodes {
if let TrackNode::Section { kind, tasks, .. } = node {
for task in tasks {
check_task(task, None, track_id, *kind, all_ids, project_root, result);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn check_task(
task: &Task,
parent: Option<&Task>,
track_id: &str,
section: crate::model::track::SectionKind,
all_ids: &HashSet<String>,
project_root: &Path,
result: &mut CheckResult,
) {
let task_id = task.id.as_deref().unwrap_or("");
if task.id.is_none() {
result.warnings.push(CheckWarning::MissingId {
track_id: track_id.to_string(),
title: task.title.clone(),
});
}
let has_added = task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)));
if !has_added && task.id.is_some() {
result.warnings.push(CheckWarning::MissingAddedDate {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
});
}
if task.state == TaskState::Done {
let has_resolved = task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Resolved(_)));
if !has_resolved {
result.warnings.push(CheckWarning::MissingResolvedDate {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
});
}
}
if parent.is_none() && task.id.is_some() {
let expected = crate::ops::task_ops::canonical_section(task.state);
if expected != section {
result.warnings.push(CheckWarning::TaskInWrongSection {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
expected,
actual: section,
});
}
}
for line in task.leading_lines.iter().filter(|l| !l.trim().is_empty()) {
result.warnings.push(CheckWarning::StrandedLine {
track_id: track_id.to_string(),
before_task_id: task.id.as_ref().map(|id| id.to_string()),
before_title: task.title.clone(),
line: line.trim().to_string(),
});
}
if let Some(id) = task.id.as_ref()
&& let Some(parent_id) = parent.and_then(|p| p.id.as_ref())
&& id.is_structured()
&& parent_id.is_structured()
&& !id.is_child_of(parent_id)
{
result.warnings.push(CheckWarning::ChildIdNotUnderParent {
track_id: track_id.to_string(),
task_id: id.to_string(),
parent_id: parent_id.to_string(),
});
}
if task.tags.iter().any(|t| t == "lost") && task.id.is_some() {
result.warnings.push(CheckWarning::LostTask {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
});
}
for meta in &task.metadata {
match meta {
Metadata::Dep(deps) => {
for dep_id in deps {
if !all_ids.contains(dep_id) {
result.errors.push(CheckError::DanglingDep {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
dep_id: dep_id.clone(),
});
}
}
}
Metadata::Ref(refs) => {
for r in refs {
if !project_root.join(r).exists() {
result.errors.push(CheckError::BrokenRef {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
path: r.clone(),
});
}
}
}
Metadata::Spec(spec) => {
let file_path = spec.split('#').next().unwrap_or(spec);
if !project_root.join(file_path).exists() {
result.errors.push(CheckError::BrokenSpec {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
path: spec.clone(),
});
}
}
Metadata::Note(note) => {
if let Some(fence) = unclosed_fence(note) {
result.warnings.push(CheckWarning::UnclosedNoteFence {
track_id: track_id.to_string(),
task_id: task.id.as_ref().map(|id| id.to_string()),
title: task.title.clone(),
fence,
});
}
}
_ => {}
}
}
for sub in &task.subtasks {
check_task(
sub,
Some(task),
track_id,
section,
all_ids,
project_root,
result,
);
}
}
fn check_inbox(inbox: &crate::model::inbox::Inbox, result: &mut CheckResult) {
for (i, item) in inbox.items.iter().enumerate() {
if let Some(ref body) = item.body
&& let Some(fence) = unclosed_fence(body)
{
result.warnings.push(CheckWarning::UnclosedInboxFence {
index: i + 1,
title: item.title.clone(),
fence,
});
}
}
}
pub(crate) fn unclosed_fence(body: &str) -> Option<String> {
let mut open: Option<(usize, String)> = None;
for line in body.lines() {
let trimmed = line.trim();
let ticks = trimmed.chars().take_while(|c| *c == '`').count();
if ticks < 3 {
continue;
}
let rest = &trimmed[ticks..];
match open {
None => {
if !rest.contains('`') {
open = Some((ticks, trimmed.to_string()));
}
}
Some((open_ticks, _)) => {
if ticks >= open_ticks && rest.trim().is_empty() {
open = None;
}
}
}
}
open.map(|(_, fence)| fence)
}
fn check_actor_registry(frame_dir: &Path, result: &mut CheckResult) {
let Some(token) = crate::io::actors::read_actor_token(frame_dir) else {
return;
};
let Ok(reg) = crate::io::actors::read_actors(frame_dir) else {
return;
};
match reg.actors.get(&token) {
None => result
.warnings
.push(CheckWarning::ActorTokenUnregistered { token }),
Some(entry) if entry.is_retired() => result
.warnings
.push(CheckWarning::ActorTokenRetiredButHeld { token }),
Some(_) => {}
}
}
fn check_actor_name_collisions(frame_dir: &Path, result: &mut CheckResult) {
let Ok(reg) = crate::io::actors::read_actors(frame_dir) else {
return;
};
let mut by_name: Vec<(String, Vec<String>)> = Vec::new();
for (token, entry) in ®.actors {
if !entry.is_active() {
continue;
}
match by_name.iter_mut().find(|(n, _)| n == &entry.name) {
Some((_, tokens)) => tokens.push(token.clone()),
None => by_name.push((entry.name.clone(), vec![token.clone()])),
}
}
for (name, tokens) in by_name {
if tokens.len() > 1 {
result
.warnings
.push(CheckWarning::ActorNameCollision { name, tokens });
}
}
}
fn check_local_files_ignored(frame_dir: &Path, result: &mut CheckResult) {
let Some(paths) = crate::io::git::repo_paths(frame_dir) else {
return;
};
let Ok(frame_rel) = frame_dir
.canonicalize()
.as_deref()
.unwrap_or(frame_dir)
.strip_prefix(&paths.toplevel)
.map(|p| p.to_path_buf())
else {
return;
};
let rel_paths: Vec<String> = crate::io::project_io::LOCAL_ONLY_FRAME_FILES
.iter()
.map(|name| frame_rel.join(name).to_string_lossy().into_owned())
.collect();
let tracked = crate::io::git::tracked_paths(&paths.toplevel, &rel_paths).unwrap_or_default();
let Some(ignored) = crate::io::git::ignored_paths(&paths.toplevel, &rel_paths) else {
return;
};
for (rel, name) in rel_paths
.iter()
.zip(crate::io::project_io::LOCAL_ONLY_FRAME_FILES)
{
let dir_prefix = format!("{rel}/");
if tracked
.iter()
.any(|t| t == rel || t.starts_with(&dir_prefix))
{
result.warnings.push(CheckWarning::LocalFileCommitted {
path: rel.clone(),
tracked: true,
});
} else if !ignored.iter().any(|i| i == rel)
&& (frame_dir.join(name).exists() || is_transient(name))
{
result.warnings.push(CheckWarning::LocalFileCommitted {
path: rel.clone(),
tracked: false,
});
}
}
}
fn collect_all_task_ids(project: &Project) -> HashSet<String> {
let mut ids = HashSet::new();
for (_, track) in &project.tracks {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_ids_from_tasks(tasks, &mut ids);
}
}
}
ids
}
fn collect_ids_from_tasks(tasks: &[Task], ids: &mut HashSet<String>) {
for task in tasks {
if let Some(ref id) = task.id {
ids.insert(id.to_string());
}
collect_ids_from_tasks(&task.subtasks, ids);
}
}
fn find_duplicate_ids(project: &Project) -> Vec<(String, Vec<String>)> {
use std::collections::HashMap;
let mut id_to_tracks: HashMap<String, Vec<String>> = HashMap::new();
for (track_id, track) in &project.tracks {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_id_locations(tasks, track_id, &mut id_to_tracks);
}
}
}
id_to_tracks
.into_iter()
.filter(|(_, tracks)| tracks.len() > 1)
.collect()
}
fn check_archived_id_collisions(project: &Project, result: &mut CheckResult) {
use std::collections::HashMap;
let mut live: HashMap<String, Vec<String>> = HashMap::new();
for (track_id, track) in &project.tracks {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_id_locations(tasks, track_id, &mut live);
}
}
}
let mut archived: HashMap<String, Vec<String>> = HashMap::new();
for (label, tasks) in archived_task_lists(&project.frame_dir) {
collect_id_locations(&tasks, &label, &mut archived);
}
let mut ids: Vec<&String> = archived.keys().collect();
ids.sort();
for id in ids {
let in_archives = &archived[id];
let in_tracks = live.get(id).cloned().unwrap_or_default();
if !in_tracks.is_empty() {
result.warnings.push(CheckWarning::IdReissuedAfterArchive {
task_id: id.clone(),
tracks: in_tracks,
archives: dedup_sorted(in_archives),
});
} else if in_archives.len() > 1 {
result.warnings.push(CheckWarning::DuplicateArchivedId {
task_id: id.clone(),
total: in_archives.len(),
archives: dedup_sorted(in_archives),
});
}
}
}
fn dedup_sorted(labels: &[String]) -> Vec<String> {
let mut out: Vec<String> = labels.to_vec();
out.sort();
out.dedup();
out
}
fn archived_task_lists(frame_dir: &Path) -> Vec<(String, Vec<Task>)> {
let mut out = Vec::new();
if let Ok(archives) = crate::io::project_io::load_archives(frame_dir) {
for (track_id, tasks) in archives {
out.push((format!("archive/{}.md", track_id), tasks));
}
}
let whole_tracks = frame_dir.join("archive").join("_tracks");
if let Ok(entries) = std::fs::read_dir(&whole_tracks) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let track = crate::parse::parse_track(&content);
let mut tasks = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { tasks: section, .. } = node {
tasks.extend(section.iter().cloned());
}
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
out.push((format!("archive/_tracks/{}", name), tasks));
}
}
out
}
fn is_transient(name: &str) -> bool {
name == crate::io::inflight::MARKER_FILE
}
fn check_inflight(frame_dir: &Path, result: &mut CheckResult) {
if let Some(marker) = crate::io::inflight::read(frame_dir) {
result.warnings.push(CheckWarning::InterruptedOperation {
operation: marker.operation.name().to_string(),
command: marker.command,
started: marker.started,
});
}
}
fn check_id_frontier(frame_dir: &Path, result: &mut CheckResult) {
let health = crate::io::ids::health(frame_dir);
if let crate::io::ids::StoreState::Unparsable(detail) = &health.state {
result.warnings.push(CheckWarning::IdFrontierUnreadable {
path: health.path.display().to_string(),
detail: detail.lines().next().unwrap_or_default().trim().to_string(),
});
}
if let Some(backup) = &health.reset_backup {
result.warnings.push(CheckWarning::IdFrontierWasReset {
path: backup.display().to_string(),
});
}
}
fn collect_id_locations(
tasks: &[Task],
track_id: &str,
id_to_tracks: &mut std::collections::HashMap<String, Vec<String>>,
) {
for task in tasks {
if let Some(ref id) = task.id {
id_to_tracks
.entry(id.to_string())
.or_default()
.push(track_id.to_string());
}
collect_id_locations(&task.subtasks, track_id, id_to_tracks);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::config::{
AgentConfig, CleanConfig, IdConfig, ProjectConfig, ProjectInfo, TrackConfig, UiConfig,
};
use crate::parse::parse_track;
use indexmap::IndexMap;
use tempfile::TempDir;
fn make_config() -> ProjectConfig {
ProjectConfig {
project: ProjectInfo {
name: "test".to_string(),
},
agent: AgentConfig::default(),
tracks: vec![TrackConfig {
id: "main".to_string(),
name: "Main".to_string(),
state: "active".to_string(),
file: "tracks/main.md".to_string(),
}],
clean: CleanConfig::default(),
ids: IdConfig {
prefixes: IndexMap::new(),
},
ui: UiConfig::default(),
}
}
fn make_project_at(root: &Path, track_src: &str) -> Project {
let track = parse_track(track_src);
Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config: make_config(),
tracks: vec![("main".to_string(), track)],
inbox: None,
}
}
fn reissue_warnings(result: &CheckResult) -> Vec<(String, Vec<String>, Vec<String>)> {
result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::IdReissuedAfterArchive {
task_id,
tracks,
archives,
} => Some((task_id.clone(), tracks.clone(), archives.clone())),
_ => None,
})
.collect()
}
fn duplicate_archive_warnings(result: &CheckResult) -> Vec<(String, usize, Vec<String>)> {
result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::DuplicateArchivedId {
task_id,
total,
archives,
} => Some((task_id.clone(), *total, archives.clone())),
_ => None,
})
.collect()
}
#[test]
fn test_check_live_id_colliding_with_an_archived_one() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive")).unwrap();
std::fs::write(
frame_dir.join("archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-005` archived work\n - resolved: 2025-06-01\n",
)
.unwrap();
let project = make_project_at(
tmp.path(),
"# Main\n\n## Backlog\n\n- [ ] `M-005` reissued\n - added: 2025-07-01\n\n## Done\n",
);
let result = check_project(&project);
assert_eq!(
reissue_warnings(&result),
vec![(
"M-005".to_string(),
vec!["main".to_string()],
vec!["archive/main.md".to_string()]
)]
);
assert!(duplicate_archive_warnings(&result).is_empty());
}
#[test]
fn test_check_duplicate_entry_within_one_archive() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive")).unwrap();
std::fs::write(
frame_dir.join("archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-007` done\n - resolved: 2025-06-01\n- [x] `M-007` done\n - resolved: 2025-06-01\n",
)
.unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let result = check_project(&project);
assert_eq!(
duplicate_archive_warnings(&result),
vec![("M-007".to_string(), 2, vec!["archive/main.md".to_string()])]
);
assert!(reissue_warnings(&result).is_empty());
}
#[test]
fn test_check_duplicate_entry_across_two_archives() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive/_tracks")).unwrap();
std::fs::write(
frame_dir.join("archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-007` done once\n",
)
.unwrap();
std::fs::write(
frame_dir.join("archive/_tracks/old.md"),
"# Old\n\n## Backlog\n\n- [ ] `M-007` same number\n\n## Done\n",
)
.unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let warnings = duplicate_archive_warnings(&check_project(&project));
assert_eq!(
warnings,
vec![(
"M-007".to_string(),
2,
vec![
"archive/_tracks/old.md".to_string(),
"archive/main.md".to_string()
]
)]
);
}
#[test]
fn test_check_archived_id_in_another_namespace_is_not_a_collision() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive")).unwrap();
std::fs::write(
frame_dir.join("archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-a5` another actor\n",
)
.unwrap();
let project = make_project_at(
tmp.path(),
"# Main\n\n## Backlog\n\n- [ ] `M-005` mine\n\n## Done\n",
);
let result = check_project(&project);
assert!(reissue_warnings(&result).is_empty());
assert!(duplicate_archive_warnings(&result).is_empty());
}
#[test]
fn test_check_archive_without_collision_is_silent() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive")).unwrap();
std::fs::write(
frame_dir.join("archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-002` old work\n",
)
.unwrap();
let project = make_project_at(
tmp.path(),
"# Main\n\n## Backlog\n\n- [ ] `M-009` current\n\n## Done\n",
);
let result = check_project(&project);
assert!(reissue_warnings(&result).is_empty());
assert!(duplicate_archive_warnings(&result).is_empty());
}
fn frontier_warnings(result: &CheckResult) -> Vec<String> {
result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::IdFrontierUnreadable { path, .. } => {
Some(format!("unreadable {path}"))
}
CheckWarning::IdFrontierWasReset { path } => Some(format!("reset {path}")),
_ => None,
})
.collect()
}
#[test]
fn test_check_flags_an_unreadable_id_frontier() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
let store = crate::io::ids::locate(&frame_dir).data;
std::fs::write(&store, "not toml {{{").unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let warnings = frontier_warnings(&check_project(&project));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].starts_with("unreadable"));
assert!(store.is_file());
}
#[test]
fn test_check_flags_a_reset_id_frontier() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
let store = crate::io::ids::locate(&frame_dir).data;
std::fs::write(store.with_extension("toml.bak"), "old\n").unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let warnings = frontier_warnings(&check_project(&project));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].starts_with("reset"));
}
#[test]
fn test_check_healthy_id_frontier_is_silent() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
crate::io::ids::reserve(&frame_dir, "M", None, 0, 1);
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
assert!(frontier_warnings(&check_project(&project)).is_empty());
}
fn git(cwd: &Path, args: &[&str]) -> bool {
std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn repo_with_local_files(root: &Path, gitignore: &str) -> Option<Project> {
if !git(root, &["init", "-q"]) {
return None;
}
let frame_dir = root.join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
std::fs::write(root.join(".gitignore"), gitignore).unwrap();
for name in crate::io::project_io::LOCAL_ONLY_FRAME_FILES {
std::fs::write(frame_dir.join(name), "local\n").unwrap();
}
Some(make_project_at(root, "# Main\n\n## Backlog\n\n## Done\n"))
}
fn gitignore_without(omit: &str) -> String {
crate::io::project_io::LOCAL_ONLY_FRAME_FILES
.iter()
.filter(|name| **name != omit)
.map(|name| format!("frame/{}\n", name))
.collect()
}
fn local_file_warnings(result: &CheckResult) -> Vec<(String, bool)> {
result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::LocalFileCommitted { path, tracked } => {
Some((path.clone(), *tracked))
}
_ => None,
})
.collect()
}
#[test]
fn test_check_local_file_not_ignored() {
let tmp = TempDir::new().unwrap();
let Some(project) = repo_with_local_files(tmp.path(), &gitignore_without(".recovery.log"))
else {
return; };
let warnings = local_file_warnings(&check_project(&project));
assert_eq!(
warnings,
vec![("frame/.recovery.log".to_string(), false)],
"only the unignored file should be flagged"
);
}
#[test]
fn test_check_local_file_tracked() {
let tmp = TempDir::new().unwrap();
let Some(project) = repo_with_local_files(tmp.path(), &gitignore_without(".recovery.log"))
else {
return;
};
if !git(tmp.path(), &["add", "frame/.recovery.log"]) {
return;
}
std::fs::write(tmp.path().join(".gitignore"), gitignore_without("")).unwrap();
let warnings = local_file_warnings(&check_project(&project));
assert_eq!(warnings, vec![("frame/.recovery.log".to_string(), true)]);
}
#[test]
fn test_check_local_files_all_ignored() {
let tmp = TempDir::new().unwrap();
let ignore: String = crate::io::project_io::LOCAL_ONLY_FRAME_FILES
.iter()
.map(|n| format!("frame/{}\n", n))
.collect();
let Some(project) = repo_with_local_files(tmp.path(), &ignore) else {
return;
};
assert!(local_file_warnings(&check_project(&project)).is_empty());
}
#[test]
fn test_check_local_files_non_git_project_is_silent() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
for name in crate::io::project_io::LOCAL_ONLY_FRAME_FILES {
std::fs::write(frame_dir.join(name), "local\n").unwrap();
}
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
if crate::io::git::repo_paths(&frame_dir).is_none() {
assert!(local_file_warnings(&check_project(&project)).is_empty());
}
}
#[test]
fn test_check_actor_name_collision() {
use crate::io::actors::{ActorRegistry, write_actors};
let tmp = TempDir::new().unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
std::fs::create_dir_all(&project.frame_dir).unwrap();
let mut reg = ActorRegistry::default();
reg.claim("null", "Mac", None, "2026-06-01").unwrap();
reg.claim("b", "ccdev", None, "2026-06-02").unwrap();
reg.claim("d", "ccdev", None, "2026-06-03").unwrap();
reg.claim("f", "ccdev", None, "2026-06-04").unwrap();
reg.retire("f", "2026-06-05").unwrap(); write_actors(&project.frame_dir, ®).unwrap();
let collisions: Vec<_> = check_project(&project)
.warnings
.into_iter()
.filter_map(|w| match w {
CheckWarning::ActorNameCollision { name, tokens } => Some((name, tokens)),
_ => None,
})
.collect();
assert_eq!(collisions.len(), 1);
assert_eq!(collisions[0].0, "ccdev");
assert_eq!(collisions[0].1, vec!["b".to_string(), "d".to_string()]);
}
#[test]
fn test_check_dangling_dep() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- dep: NONEXIST-999
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
&result.errors[0],
CheckError::DanglingDep { dep_id, .. } if dep_id == "NONEXIST-999"
));
}
#[test]
fn test_check_valid_dep() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- dep: M-002
- [ ] `M-002` Task two
- added: 2025-05-01
## Done
",
);
let result = check_project(&project);
assert!(result.valid);
let dangling: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, CheckError::DanglingDep { .. }))
.collect();
assert!(dangling.is_empty());
}
#[test]
fn test_check_cross_track_dep() {
let tmp = TempDir::new().unwrap();
let track_a = parse_track(
"\
# A
## Backlog
- [ ] `A-001` Task A
- added: 2025-05-01
- dep: B-001
## Done
",
);
let track_b = parse_track(
"\
# B
## Backlog
- [ ] `B-001` Task B
- added: 2025-05-01
## Done
",
);
let mut config = make_config();
config.tracks = vec![
TrackConfig {
id: "a".to_string(),
name: "A".to_string(),
state: "active".to_string(),
file: "a.md".to_string(),
},
TrackConfig {
id: "b".to_string(),
name: "B".to_string(),
state: "active".to_string(),
file: "b.md".to_string(),
},
];
let project = Project {
root: tmp.path().to_path_buf(),
frame_dir: tmp.path().join("frame"),
config,
tracks: vec![("a".to_string(), track_a), ("b".to_string(), track_b)],
inbox: None,
};
let result = check_project(&project);
assert!(result.valid);
}
#[test]
fn test_check_broken_ref() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- ref: nonexistent/file.md
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert!(matches!(
&result.errors[0],
CheckError::BrokenRef { path, .. } if path == "nonexistent/file.md"
));
}
#[test]
fn test_check_valid_ref() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("doc.md"), "content").unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- ref: doc.md
## Done
",
);
let result = check_project(&project);
let broken: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, CheckError::BrokenRef { .. }))
.collect();
assert!(broken.is_empty());
}
#[test]
fn test_check_broken_spec() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- spec: missing/spec.md#section
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert!(matches!(
&result.errors[0],
CheckError::BrokenSpec { path, .. } if path == "missing/spec.md#section"
));
}
#[test]
fn test_check_valid_spec_with_section() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("doc")).unwrap();
std::fs::write(tmp.path().join("doc/spec.md"), "# Section\ncontent").unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- spec: doc/spec.md#section
## Done
",
);
let result = check_project(&project);
let broken: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, CheckError::BrokenSpec { .. }))
.collect();
assert!(broken.is_empty());
}
#[test]
fn test_check_duplicate_ids() {
let tmp = TempDir::new().unwrap();
let track_a = parse_track(
"\
# A
## Backlog
- [ ] `DUP-001` Task in A
- added: 2025-05-01
## Done
",
);
let track_b = parse_track(
"\
# B
## Backlog
- [ ] `DUP-001` Same ID in B
- added: 2025-05-01
## Done
",
);
let mut config = make_config();
config.tracks = vec![
TrackConfig {
id: "a".to_string(),
name: "A".to_string(),
state: "active".to_string(),
file: "a.md".to_string(),
},
TrackConfig {
id: "b".to_string(),
name: "B".to_string(),
state: "active".to_string(),
file: "b.md".to_string(),
},
];
let project = Project {
root: tmp.path().to_path_buf(),
frame_dir: tmp.path().join("frame"),
config,
tracks: vec![("a".to_string(), track_a), ("b".to_string(), track_b)],
inbox: None,
};
let result = check_project(&project);
assert!(!result.valid);
assert!(
result.errors.iter().any(
|e| matches!(e, CheckError::DuplicateId { task_id, .. } if task_id == "DUP-001")
)
);
}
#[test]
fn test_check_duplicate_ids_within_track() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` First occurrence
- added: 2025-05-01
- [ ] `M-001` Same ID in same track
- added: 2025-05-02
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert!(
result.errors.iter().any(
|e| matches!(e, CheckError::DuplicateId { task_id, track_ids } if task_id == "M-001" && track_ids.len() == 2)
)
);
}
fn misparented(result: &CheckResult) -> Vec<(String, String)> {
result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::ChildIdNotUnderParent {
task_id, parent_id, ..
} => Some((task_id.clone(), parent_id.clone())),
_ => None,
})
.collect()
}
#[test]
fn test_check_reports_a_subtask_holding_a_top_level_id() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-007` Escaped
- added: 2025-05-01
## Done
",
);
assert_eq!(
misparented(&check_project(&project)),
vec![("M-007".to_string(), "M-001".to_string())]
);
}
#[test]
fn test_check_reports_a_subtask_under_the_wrong_parent() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` One
- added: 2025-05-01
- [ ] `M-002` Two
- added: 2025-05-01
- [ ] `M-001.3` Belongs to M-001
- added: 2025-05-01
## Done
",
);
assert_eq!(
misparented(&check_project(&project)),
vec![("M-001.3".to_string(), "M-002".to_string())]
);
}
#[test]
fn test_check_reports_a_grandchild_id_on_a_child() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1.1` Too deep
- added: 2025-05-01
## Done
",
);
assert_eq!(
misparented(&check_project(&project)),
vec![("M-001.1.1".to_string(), "M-001".to_string())]
);
}
#[test]
fn test_check_accepts_well_formed_subtask_ids() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Ours
- added: 2025-05-01
- [ ] `M-001.b2` Theirs
- added: 2025-05-01
- [ ] `M-001.b2.1` Deep
- added: 2025-05-01
## Done
",
);
assert!(misparented(&check_project(&project)).is_empty());
}
#[test]
fn test_check_leaves_raw_ids_alone() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `legacy/thing` Parent
- added: 2025-05-01
- [ ] `M-001.1` Structured child of a raw parent
- added: 2025-05-01
- [ ] `M-002` Structured parent
- added: 2025-05-01
- [ ] `whatever` Raw child
- added: 2025-05-01
## Done
",
);
assert!(misparented(&check_project(&project)).is_empty());
}
#[test]
fn test_warn_missing_id() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] Task without ID
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::MissingId { title, .. } if title == "Task without ID"
)));
}
#[test]
fn test_warn_missing_added_date() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task without added date
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::MissingAddedDate { task_id, .. } if task_id == "M-001"
)));
}
#[test]
fn test_warn_missing_resolved_date() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
## Done
- [x] `M-001` Done task without resolved
- added: 2025-05-01
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::MissingResolvedDate { task_id, .. } if task_id == "M-001"
)));
}
#[test]
fn test_warn_done_in_backlog() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [x] `M-001` Done task in backlog
- added: 2025-05-01
- resolved: 2025-05-10
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::TaskInWrongSection { task_id, expected, actual, .. }
if task_id == "M-001"
&& *expected == crate::model::track::SectionKind::Done
&& *actual == crate::model::track::SectionKind::Backlog
)));
}
#[test]
fn test_warn_lost_task() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [!] `M-001` Recovered task #lost
- added: 2025-05-01
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::LostTask { task_id, .. } if task_id == "M-001"
)));
}
#[test]
fn test_unclosed_fence_detection() {
assert_eq!(unclosed_fence("```rust\nfn main() {}\n```"), None);
assert_eq!(unclosed_fence("```lace\n```rust\n```"), None);
assert_eq!(unclosed_fence("just some prose\nover two lines"), None);
assert_eq!(unclosed_fence("call `foo()` then `bar()`"), None);
assert_eq!(unclosed_fence("```\ncode\n`````"), None);
assert_eq!(unclosed_fence("`````\ncode\n```").as_deref(), Some("`````"));
assert_eq!(unclosed_fence("Example:\n```").as_deref(), Some("```"));
assert_eq!(
unclosed_fence("```rust\nfn main() {}").as_deref(),
Some("```rust")
);
assert_eq!(
unclosed_fence("```\na\n```\nprose\n```py").as_deref(),
Some("```py")
);
}
#[test]
fn test_warn_unclosed_note_fence() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task with an unclosed fence
- added: 2025-05-01
- note:
Example:
```rust
fn main() {}
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::UnclosedNoteFence { task_id, fence, .. }
if task_id.as_deref() == Some("M-001") && fence == "```rust"
)));
assert!(result.valid);
}
#[test]
fn test_no_fence_warning_for_balanced_note() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task with balanced fences
- added: 2025-05-01
- note:
§13.4 mentions three fence kinds:
```lace
```rust
```
Check the spec.
## Done
",
);
let result = check_project(&project);
assert!(
!result
.warnings
.iter()
.any(|w| matches!(w, CheckWarning::UnclosedNoteFence { .. }))
);
}
#[test]
fn test_warn_stranded_line() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Done
- [x] `M-001` Sharded map lowering
- resolved: 2026-07-20
**Shape.** A line that lost its indent.
- [x] `M-002` Next task
- resolved: 2026-07-21
",
);
let result = check_project(&project);
let stranded: Vec<_> = result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::StrandedLine {
before_task_id,
line,
..
} => Some((before_task_id.clone(), line.clone())),
_ => None,
})
.collect();
assert_eq!(
stranded,
vec![(
Some("M-002".to_string()),
"**Shape.** A line that lost its indent.".to_string()
)]
);
assert!(result.valid);
}
#[test]
fn test_no_stranded_warning_for_a_clean_track() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task
- added: 2025-05-01
- note:
A note with
two lines.
- [ ] `M-001.1` Subtask
## Done
",
);
let result = check_project(&project);
assert!(
!result
.warnings
.iter()
.any(|w| matches!(w, CheckWarning::StrandedLine { .. }))
);
}
#[test]
fn test_warn_unclosed_note_fence_on_task_without_id() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] Unnamed task
- note:
```
## Done
",
);
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::UnclosedNoteFence { task_id, title, .. }
if task_id.is_none() && title == "Unnamed task"
)));
}
#[test]
fn test_warn_unclosed_inbox_fence() {
let tmp = TempDir::new().unwrap();
let mut project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let (inbox, _) = crate::parse::parse_inbox(
"\
# Inbox
- Balanced item
```
code
```
- Item with an unclosed fence
Example:
```py
- Trailing item
",
);
project.inbox = Some(inbox);
let result = check_project(&project);
let fence_warnings: Vec<_> = result
.warnings
.iter()
.filter_map(|w| match w {
CheckWarning::UnclosedInboxFence { index, fence, .. } => Some((*index, fence)),
_ => None,
})
.collect();
assert_eq!(fence_warnings.len(), 1);
assert_eq!(fence_warnings[0].0, 2);
assert_eq!(fence_warnings[0].1, "```py");
}
#[test]
fn test_no_lost_warning_without_tag() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Normal task #core
- added: 2025-05-01
## Done
",
);
let result = check_project(&project);
assert!(
!result
.warnings
.iter()
.any(|w| matches!(w, CheckWarning::LostTask { .. }))
);
}
#[test]
fn test_lost_task_no_id_no_warning() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] Task without ID #lost
## Done
",
);
let result = check_project(&project);
assert!(
result
.warnings
.iter()
.any(|w| matches!(w, CheckWarning::MissingId { .. }))
);
assert!(
!result
.warnings
.iter()
.any(|w| matches!(w, CheckWarning::LostTask { .. }))
);
}
#[test]
fn test_check_recovery_log_info() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
crate::io::recovery::log_recovery(
&frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Write,
description: "test write failure".to_string(),
fields: vec![],
body: "lost content".to_string(),
},
);
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task
- added: 2025-05-01
## Done
",
);
let result = check_project(&project);
assert!(result.info.iter().any(|i| matches!(
i,
CheckInfo::RecoveryLog { entry_count, .. } if *entry_count == 1
)));
}
#[test]
fn test_check_no_recovery_log_info() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task
- added: 2025-05-01
## Done
",
);
let result = check_project(&project);
assert!(result.info.is_empty());
}
#[test]
fn test_check_clean_project() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Well-formed task
- added: 2025-05-01
- [>] `M-002` Another good task
- added: 2025-05-02
## Done
- [x] `M-000` Completed task
- added: 2025-04-01
- resolved: 2025-05-01
",
);
let result = check_project(&project);
assert!(result.valid);
assert!(result.errors.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn test_check_subtask_dangling_dep() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Sub with bad dep
- added: 2025-05-01
- dep: GONE-999
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert!(matches!(
&result.errors[0],
CheckError::DanglingDep { task_id, dep_id, .. }
if task_id == "M-001.1" && dep_id == "GONE-999"
));
}
#[test]
fn test_check_duplicate_token_namespaces() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `EFF-a14` Same namespace, occurrence one
- added: 2025-05-01
- [ ] `EFF-a14` Same namespace, occurrence two
- added: 2025-05-02
- [ ] `EFF-14` Null namespace, not a dup of a14
- added: 2025-05-03
- [ ] `EFF-b14` Different token, not a dup of a14
- added: 2025-05-04
## Done
",
);
let result = check_project(&project);
let dups: Vec<&String> = result
.errors
.iter()
.filter_map(|e| match e {
CheckError::DuplicateId { task_id, .. } => Some(task_id),
_ => None,
})
.collect();
assert_eq!(dups, vec![&"EFF-a14".to_string()]);
assert!(!dups.iter().any(|id| *id == "EFF-14"));
assert!(!dups.iter().any(|id| *id == "EFF-b14"));
}
#[test]
fn test_check_clean_tokened_project() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Null-namespace task
- added: 2025-05-01
- [ ] `M-a1` Tokened task depending on a null id
- added: 2025-05-02
- dep: M-001
- [ ] `M-b2` Another tokened task depending on a tokened id
- added: 2025-05-03
- dep: M-a1
- [ ] `M-b2.c1` Tokened subtask depending on a null id
- added: 2025-05-04
- dep: M-001
## Done
",
);
let result = check_project(&project);
assert!(result.valid, "expected valid, got {:?}", result.errors);
assert!(result.errors.is_empty());
}
#[test]
fn test_check_dangling_dep_tokened() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-014` A null-namespace task
- added: 2025-05-01
- [ ] `M-a1` Depends on a tokened id that does not exist
- added: 2025-05-02
- dep: M-a14
## Done
",
);
let result = check_project(&project);
assert!(!result.valid);
assert!(result.errors.iter().any(|e| matches!(
e,
CheckError::DanglingDep { dep_id, .. } if dep_id == "M-a14"
)));
}
#[test]
fn test_check_actor_token_unregistered() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
crate::io::actors::write_actor_token(&frame_dir, "null").unwrap();
std::fs::write(
frame_dir.join("actors.toml"),
"[actors.b]\nname = \"ccdev\"\nstate = \"active\"\n",
)
.unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::ActorTokenUnregistered { token } if token == "null"
)));
}
#[test]
fn test_check_actor_token_retired_but_held() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
crate::io::actors::write_actor_token(&frame_dir, "a").unwrap();
std::fs::write(
frame_dir.join("actors.toml"),
"[actors.a]\nname = \"host\"\nstate = \"retired\"\nretired = \"2026-06-27\"\n",
)
.unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let result = check_project(&project);
assert!(result.warnings.iter().any(|w| matches!(
w,
CheckWarning::ActorTokenRetiredButHeld { token } if token == "a"
)));
}
#[test]
fn test_check_actor_token_healthy_and_unclaimed() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
crate::io::actors::write_actor_token(&frame_dir, "a").unwrap();
std::fs::write(
frame_dir.join("actors.toml"),
"[actors.a]\nname = \"host\"\nstate = \"active\"\nclaimed = \"2026-06-27\"\n",
)
.unwrap();
let project = make_project_at(tmp.path(), "# Main\n\n## Backlog\n\n## Done\n");
let result = check_project(&project);
assert!(!result.warnings.iter().any(|w| matches!(
w,
CheckWarning::ActorTokenUnregistered { .. }
| CheckWarning::ActorTokenRetiredButHeld { .. }
)));
let tmp2 = TempDir::new().unwrap();
std::fs::create_dir_all(tmp2.path().join("frame")).unwrap();
let project2 = make_project_at(tmp2.path(), "# Main\n\n## Backlog\n\n## Done\n");
let result2 = check_project(&project2);
assert!(!result2.warnings.iter().any(|w| matches!(
w,
CheckWarning::ActorTokenUnregistered { .. }
| CheckWarning::ActorTokenRetiredButHeld { .. }
)));
}
#[test]
fn test_check_result_serializes_to_json() {
let tmp = TempDir::new().unwrap();
let project = make_project_at(
tmp.path(),
"\
# Main
## Backlog
- [ ] `M-001` Task
- added: 2025-05-01
- dep: GONE-001
## Done
",
);
let result = check_project(&project);
let json = serde_json::to_string_pretty(&result).unwrap();
assert!(json.contains("dangling_dep"));
assert!(json.contains("GONE-001"));
}
}