Skip to main content

ito_core/
archive.rs

1//! Archive completed changes.
2//!
3//! Archiving moves a change directory into the archive area and can copy spec
4//! deltas back into the main `specs/` tree.
5//!
6//! This module also includes a small helper for determining whether a
7//! `tasks.md` file is fully complete.
8
9use std::fs;
10use std::path::Path;
11
12use chrono::Utc;
13
14use crate::error_bridge::IntoCoreResult;
15use crate::errors::{CoreError, CoreResult};
16use ito_common::fs::StdFs;
17use ito_common::id::parse_change_id;
18use ito_common::paths;
19use ito_domain::modules::ModuleRepository as DomainModuleRepository;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22/// Summary of task completion for a change.
23pub enum TaskStatus {
24    /// The file contains no recognized tasks.
25    NoTasks,
26    /// All recognized tasks are complete.
27    AllComplete,
28    /// Some tasks are incomplete.
29    HasIncomplete {
30        /// Number of incomplete tasks.
31        pending: usize,
32        /// Total number of recognized tasks.
33        total: usize,
34    },
35}
36
37/// Check whether the tasks in `contents` are complete.
38///
39/// Supports both the checkbox task format (`- [ ]`, `- [x]`, `- [~]`, `- [>]`)
40/// and the enhanced format (`- **Status**: [ ] pending`).
41pub fn check_task_completion(contents: &str) -> TaskStatus {
42    // Support both:
43    // - checkbox tasks: "- [ ]" / "- [x]" / "- [~]" / "- [>]"
44    // - enhanced tasks: "- **Status**: [ ] pending" / "- **Status**: [x] completed"
45    let mut total = 0usize;
46    let mut pending = 0usize;
47
48    for raw in contents.lines() {
49        let line = raw.trim();
50        if line.starts_with("- [ ]") || line.starts_with("* [ ]") {
51            total += 1;
52            pending += 1;
53            continue;
54        }
55        if line.starts_with("- [~]")
56            || line.starts_with("- [>]")
57            || line.starts_with("* [~]")
58            || line.starts_with("* [>]")
59        {
60            total += 1;
61            pending += 1;
62            continue;
63        }
64        if line.starts_with("- [x]")
65            || line.starts_with("- [X]")
66            || line.starts_with("* [x]")
67            || line.starts_with("* [X]")
68        {
69            total += 1;
70            continue;
71        }
72
73        if line.starts_with("- **Status**:") || line.contains("**Status**:") {
74            // Expect: - **Status**: [ ] pending OR - **Status**: [x] completed
75            if line.contains("[ ]") {
76                total += 1;
77                pending += 1;
78                continue;
79            }
80            if line.contains("[x]") || line.contains("[X]") {
81                total += 1;
82                continue;
83            }
84        }
85    }
86
87    if total == 0 {
88        return TaskStatus::NoTasks;
89    }
90    if pending == 0 {
91        return TaskStatus::AllComplete;
92    }
93    TaskStatus::HasIncomplete { pending, total }
94}
95
96/// List available change directories under `{ito_path}/changes`.
97pub fn list_available_changes(ito_path: &Path) -> CoreResult<Vec<String>> {
98    let fs = StdFs;
99    ito_domain::discovery::list_change_dir_names(&fs, ito_path).into_core()
100}
101
102/// Return `true` if the change exists.
103///
104/// Existence is determined by presence of `{change}/proposal.md`.
105pub fn change_exists(ito_path: &Path, change_name: &str) -> bool {
106    if change_name.trim().is_empty() {
107        return false;
108    }
109    let proposal = paths::change_dir(ito_path, change_name).join("proposal.md");
110    proposal.exists()
111}
112
113/// Generate an archive folder name for `change_name`.
114pub fn generate_archive_name(change_name: &str) -> String {
115    let date = Utc::now().format("%Y-%m-%d").to_string();
116    format!("{date}-{change_name}")
117}
118
119/// Return `true` if `{ito_path}/changes/archive/{archive_name}` exists.
120pub fn archive_exists(ito_path: &Path, archive_name: &str) -> bool {
121    let dir = paths::changes_archive_dir(ito_path).join(archive_name);
122    dir.exists()
123}
124
125/// Discover spec ids present under `{change}/specs/*/spec.md`.
126pub fn discover_change_specs(ito_path: &Path, change_name: &str) -> CoreResult<Vec<String>> {
127    let mut out: Vec<String> = Vec::new();
128    let specs_dir = paths::change_specs_dir(ito_path, change_name);
129    if !specs_dir.exists() {
130        return Ok(out);
131    }
132
133    let entries = fs::read_dir(&specs_dir)
134        .map_err(|e| CoreError::io(format!("reading {}", specs_dir.display()), e))?;
135    for entry in entries {
136        let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
137        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
138        if !is_dir {
139            continue;
140        }
141        let name = entry.file_name().to_string_lossy().to_string();
142        if name.trim().is_empty() {
143            continue;
144        }
145        let spec_md = entry.path().join("spec.md");
146        if !spec_md.exists() {
147            continue;
148        }
149        out.push(name);
150    }
151
152    out.sort();
153    Ok(out)
154}
155
156/// Split spec ids into those already present in main specs and those that are new.
157pub fn categorize_specs(ito_path: &Path, spec_names: &[String]) -> (Vec<String>, Vec<String>) {
158    let mut new_specs: Vec<String> = Vec::new();
159    let mut existing_specs: Vec<String> = Vec::new();
160    for spec in spec_names {
161        let dst = paths::spec_markdown_path(ito_path, spec);
162        if dst.exists() {
163            existing_specs.push(spec.clone());
164        } else {
165            new_specs.push(spec.clone());
166        }
167    }
168    (new_specs, existing_specs)
169}
170
171/// Copy change spec deltas to the main specs tree.
172///
173/// Returns the list of spec ids that were written.
174pub fn copy_specs_to_main(
175    ito_path: &Path,
176    change_name: &str,
177    spec_names: &[String],
178) -> CoreResult<Vec<String>> {
179    let mut updated: Vec<String> = Vec::new();
180    for spec in spec_names {
181        let src = paths::change_specs_dir(ito_path, change_name)
182            .join(spec)
183            .join("spec.md");
184        if !src.exists() {
185            continue;
186        }
187        let dst_dir = paths::specs_dir(ito_path).join(spec);
188        ito_common::io::create_dir_all_std(&dst_dir)
189            .map_err(|e| CoreError::io(format!("creating spec dir {}", dst_dir.display()), e))?;
190        let dst = dst_dir.join("spec.md");
191        let md = ito_common::io::read_to_string_std(&src)
192            .map_err(|e| CoreError::io(format!("reading spec {}", src.display()), e))?;
193        ito_common::io::write_std(&dst, md)
194            .map_err(|e| CoreError::io(format!("writing spec {}", dst.display()), e))?;
195        updated.push(spec.clone());
196    }
197    Ok(updated)
198}
199
200fn mark_change_complete_in_module(
201    module_repo: &impl DomainModuleRepository,
202    ito_path: &Path,
203    change_name: &str,
204) {
205    let Ok(parsed) = parse_change_id(change_name) else {
206        return;
207    };
208    let module_id = parsed.module_id;
209    let Ok(Some(resolved)) =
210        crate::validate::resolve_module(module_repo, ito_path, module_id.as_str())
211    else {
212        return;
213    };
214    let Ok(md) = ito_common::io::read_to_string_std(&resolved.module_md) else {
215        return;
216    };
217
218    let mut out = String::new();
219    for line in md.lines() {
220        if line.contains(change_name) {
221            out.push_str(&line.replace("- [ ]", "- [x]"));
222            out.push('\n');
223            continue;
224        }
225        out.push_str(line);
226        out.push('\n');
227    }
228    let _ = ito_common::io::write_std(&resolved.module_md, out);
229}
230
231/// Move a change directory to the archive location.
232pub fn move_to_archive(
233    module_repo: &impl DomainModuleRepository,
234    ito_path: &Path,
235    change_name: &str,
236    archive_name: &str,
237) -> CoreResult<()> {
238    let change_dir = paths::change_dir(ito_path, change_name);
239    if !change_dir.exists() {
240        return Err(CoreError::not_found(format!(
241            "Change '{change_name}' not found"
242        )));
243    }
244
245    let archive_root = paths::changes_archive_dir(ito_path);
246    ito_common::io::create_dir_all_std(&archive_root)
247        .map_err(|e| CoreError::io("creating archive directory", e))?;
248
249    let dst = archive_root.join(archive_name);
250    if dst.exists() {
251        return Err(CoreError::validation(format!(
252            "Archive target already exists: {}",
253            dst.display()
254        )));
255    }
256
257    mark_change_complete_in_module(module_repo, ito_path, change_name);
258
259    fs::rename(&change_dir, &dst).map_err(|e| CoreError::io("moving change to archive", e))?;
260    Ok(())
261}