|
4 | 4 | //! - invalidate: архівує version chain у `history/<ts>-invalidate/`, нова |
5 | 5 | //! chain стартує з NNN=001; каскад вниз по нащадках; без sentinel-файлів — |
6 | 6 | //! стан derived з відсутності `fact_*.md`. |
7 | | -//! - kill: архівує весь вузол у `<tasks-root>/.history/<ts>-kill-<path>/` |
8 | | -//! і прибирає директорію; каскад повний за визначенням (піддерево). |
| 7 | +//! - kill: якщо піддерево вузла (сам вузол + нащадки) не має жодного |
| 8 | +//! run-артефакту (chain-файли, `run-summary.md`, `history/`) — вузол |
| 9 | +//! видаляється назавжди (не було що архівувати, помилково створений |
| 10 | +//! вузол); інакше архівується у `<tasks-root>/.history/<ts>-kill-<path>/` |
| 11 | +//! і прибирається директорія; каскад повний за визначенням (піддерево). |
9 | 12 |
|
10 | 13 | use std::fs; |
11 | 14 | use std::path::Path; |
@@ -118,16 +121,45 @@ fn invalidate_rec( |
118 | 121 | Ok(()) |
119 | 122 | } |
120 | 123 |
|
121 | | -/// `mt kill <path>` (файловий рівень): архівує весь вузол з нащадками у |
122 | | -/// `<tasks-root>/.history/<ts>-kill-<path>/` і прибирає директорію. |
123 | | -/// Повертає шлях архіву відносно tasks root. |
| 124 | +/// Чи має вузол (без рекурсії в нащадків) артефакти запуску: chain-файли, |
| 125 | +/// `run-summary.md`, або `history/` (архів попередніх invalidate). |
| 126 | +fn has_run_artifacts_here(dir: &Path) -> bool { |
| 127 | + if dir.join("run-summary.md").is_file() || dir.join("history").is_dir() { |
| 128 | + return true; |
| 129 | + } |
| 130 | + let Ok(entries) = fs::read_dir(dir) else { |
| 131 | + return false; |
| 132 | + }; |
| 133 | + entries.flatten().any(|entry| { |
| 134 | + entry.file_type().map(|t| t.is_file()).unwrap_or(false) |
| 135 | + && is_chain_file(&entry.file_name().to_string_lossy()) |
| 136 | + }) |
| 137 | +} |
| 138 | + |
| 139 | +/// Чи має піддерево вузла (сам вузол + всі нащадки) бодай один run-артефакт. |
| 140 | +fn has_run_artifacts(dir: &Path) -> bool { |
| 141 | + has_run_artifacts_here(dir) |
| 142 | + || child_nodes(dir) |
| 143 | + .iter() |
| 144 | + .any(|c| has_run_artifacts(&dir.join(c))) |
| 145 | +} |
| 146 | + |
| 147 | +/// `mt kill <path>` (файловий рівень): якщо піддерево вузла ще не мало |
| 148 | +/// жодного запуску — видаляє його назавжди; інакше архівує весь вузол |
| 149 | +/// з нащадками у `<tasks-root>/.history/<ts>-kill-<path>/` і прибирає |
| 150 | +/// директорію. Повертає `.history/<archive>` (архівовано) або |
| 151 | +/// `deleted:<node_path>` (видалено без історії). |
124 | 152 | pub fn kill(tasks_dir: &str, node_path: &str) -> Result<String, String> { |
125 | 153 | validate_name(node_path)?; |
126 | 154 | let root = Path::new(tasks_dir); |
127 | 155 | let dir = root.join(node_path); |
128 | 156 | if !dir.join("task.md").is_file() { |
129 | 157 | return Err(format!("node not found: {node_path}")); |
130 | 158 | } |
| 159 | + if !has_run_artifacts(&dir) { |
| 160 | + fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; |
| 161 | + return Ok(format!("deleted:{node_path}")); |
| 162 | + } |
131 | 163 | let archive_name = format!("{}-kill-{}", timestamp(), node_path.replace('/', "-")); |
132 | 164 | let history = root.join(".history"); |
133 | 165 | fs::create_dir_all(&history).map_err(|e| e.to_string())?; |
@@ -229,4 +261,36 @@ mod tests { |
229 | 261 | assert!(kill(&root, "nope").is_err()); |
230 | 262 | assert!(kill(&root, "../escape").is_err()); |
231 | 263 | } |
| 264 | + |
| 265 | + #[test] |
| 266 | + fn kill_deletes_fresh_node_without_run_history() { |
| 267 | + let tmp = tempfile::tempdir().unwrap(); |
| 268 | + let node = tmp.path().join("draft"); |
| 269 | + fs::create_dir_all(&node).unwrap(); |
| 270 | + fs::write(node.join("task.md"), "x").unwrap(); |
| 271 | + fs::write(node.join("plan_001.md"), "x").unwrap(); |
| 272 | + |
| 273 | + let root = tmp.path().to_string_lossy().into_owned(); |
| 274 | + let result = kill(&root, "draft").unwrap(); |
| 275 | + assert_eq!(result, "deleted:draft"); |
| 276 | + assert!(!node.exists()); |
| 277 | + assert!(!tmp.path().join(".history").exists()); |
| 278 | + } |
| 279 | + |
| 280 | + #[test] |
| 281 | + fn kill_archives_when_only_a_descendant_has_run_history() { |
| 282 | + let tmp = tempfile::tempdir().unwrap(); |
| 283 | + let node = tmp.path().join("draft"); |
| 284 | + let child = node.join("sub"); |
| 285 | + fs::create_dir_all(&child).unwrap(); |
| 286 | + fs::write(node.join("task.md"), "x").unwrap(); |
| 287 | + fs::write(child.join("task.md"), "x").unwrap(); |
| 288 | + fs::write(child.join("run_001.md"), "x").unwrap(); |
| 289 | + |
| 290 | + let root = tmp.path().to_string_lossy().into_owned(); |
| 291 | + let archive = kill(&root, "draft").unwrap(); |
| 292 | + assert!(archive.starts_with(".history/")); |
| 293 | + assert!(!node.exists()); |
| 294 | + assert!(tmp.path().join(&archive).join("sub/run_001.md").is_file()); |
| 295 | + } |
232 | 296 | } |
0 commit comments