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 crate::module_repository::FsModuleRepository;
17use ito_common::fs::StdFs;
18use ito_common::id::parse_change_id;
19use ito_common::paths;
20
21#[path = "archive_specs.rs"]
22mod archive_specs;
23
24pub(crate) fn reconcile_spec_markdown(
25    base: Option<&str>,
26    delta: &str,
27) -> CoreResult<Option<String>> {
28    archive_specs::reconcile_spec(base, delta)
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32/// Summary of task completion for a change.
33pub enum TaskStatus {
34    /// The file contains no recognized tasks.
35    NoTasks,
36    /// All recognized tasks are complete.
37    AllComplete,
38    /// Some tasks are incomplete.
39    HasIncomplete {
40        /// Number of incomplete tasks.
41        pending: usize,
42        /// Total number of recognized tasks.
43        total: usize,
44    },
45}
46
47/// Check whether the tasks in `contents` are complete.
48///
49/// Supports both the checkbox task format (`- [ ]`, `- [x]`, `- [~]`, `- [>]`)
50/// and the enhanced format (`- **Status**: [ ] pending`).
51pub fn check_task_completion(contents: &str) -> TaskStatus {
52    // Support both:
53    // - checkbox tasks: "- [ ]" / "- [x]" / "- [~]" / "- [>]"
54    // - enhanced tasks: "- **Status**: [ ] pending" / "- **Status**: [x] completed"
55    let mut total = 0usize;
56    let mut pending = 0usize;
57
58    for raw in contents.lines() {
59        let line = raw.trim();
60        if line.starts_with("- [ ]") || line.starts_with("* [ ]") {
61            total += 1;
62            pending += 1;
63            continue;
64        }
65        if line.starts_with("- [~]")
66            || line.starts_with("- [>]")
67            || line.starts_with("* [~]")
68            || line.starts_with("* [>]")
69        {
70            total += 1;
71            pending += 1;
72            continue;
73        }
74        if line.starts_with("- [x]")
75            || line.starts_with("- [X]")
76            || line.starts_with("* [x]")
77            || line.starts_with("* [X]")
78        {
79            total += 1;
80            continue;
81        }
82
83        if line.starts_with("- **Status**:") || line.contains("**Status**:") {
84            // Expect: - **Status**: [ ] pending OR - **Status**: [x] completed
85            if line.contains("[ ]") {
86                total += 1;
87                pending += 1;
88                continue;
89            }
90            if line.contains("[x]") || line.contains("[X]") {
91                total += 1;
92                continue;
93            }
94        }
95    }
96
97    if total == 0 {
98        return TaskStatus::NoTasks;
99    }
100    if pending == 0 {
101        return TaskStatus::AllComplete;
102    }
103    TaskStatus::HasIncomplete { pending, total }
104}
105
106/// List available change directories under `{ito_path}/changes`.
107pub fn list_available_changes(ito_path: &Path) -> CoreResult<Vec<String>> {
108    let fs = StdFs;
109    ito_domain::discovery::list_change_dir_names(&fs, ito_path).into_core()
110}
111
112/// Return `true` if the change exists.
113///
114/// Existence is determined by presence of `{change}/proposal.md`.
115pub fn change_exists(ito_path: &Path, change_name: &str) -> bool {
116    if change_name.trim().is_empty() {
117        return false;
118    }
119    let proposal = paths::change_dir(ito_path, change_name).join("proposal.md");
120    proposal.exists()
121}
122
123/// Generate an archive folder name for `change_name`.
124pub fn generate_archive_name(change_name: &str) -> String {
125    let date = Utc::now().format("%Y-%m-%d").to_string();
126    format!("{date}-{change_name}")
127}
128
129/// Return `true` if `{ito_path}/changes/archive/{archive_name}` exists.
130pub fn archive_exists(ito_path: &Path, archive_name: &str) -> bool {
131    let dir = paths::changes_archive_dir(ito_path).join(archive_name);
132    dir.exists()
133}
134
135/// Discover spec ids present under `{change}/specs/*/spec.md`.
136pub fn discover_change_specs(ito_path: &Path, change_name: &str) -> CoreResult<Vec<String>> {
137    let mut out: Vec<String> = Vec::new();
138    let specs_dir = paths::change_specs_dir(ito_path, change_name);
139    if !specs_dir.exists() {
140        return Ok(out);
141    }
142
143    let entries = fs::read_dir(&specs_dir)
144        .map_err(|e| CoreError::io(format!("reading {}", specs_dir.display()), e))?;
145    for entry in entries {
146        let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
147        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
148        if !is_dir {
149            continue;
150        }
151        let name = entry.file_name().to_string_lossy().to_string();
152        if name.trim().is_empty() {
153            continue;
154        }
155        let spec_md = entry.path().join("spec.md");
156        if !spec_md.exists() {
157            continue;
158        }
159        out.push(name);
160    }
161
162    out.sort();
163    Ok(out)
164}
165
166/// Split spec ids into those already present in main specs and those that are new.
167pub fn categorize_specs(ito_path: &Path, spec_names: &[String]) -> (Vec<String>, Vec<String>) {
168    let mut new_specs: Vec<String> = Vec::new();
169    let mut existing_specs: Vec<String> = Vec::new();
170    for spec in spec_names {
171        let dst = paths::spec_markdown_path(ito_path, spec);
172        if dst.exists() {
173            existing_specs.push(spec.clone());
174        } else {
175            new_specs.push(spec.clone());
176        }
177    }
178    (new_specs, existing_specs)
179}
180
181/// Reconcile change spec deltas into the main specs tree.
182///
183/// Returns the list of spec ids that were written.
184pub fn copy_specs_to_main(
185    ito_path: &Path,
186    change_name: &str,
187    spec_names: &[String],
188) -> CoreResult<Vec<String>> {
189    let mut pending = Vec::new();
190    for spec in spec_names {
191        let src = paths::change_specs_dir(ito_path, change_name)
192            .join(spec)
193            .join("spec.md");
194        if !src.exists() {
195            continue;
196        }
197        let delta = ito_common::io::read_to_string_std(&src)
198            .map_err(|e| CoreError::io(format!("reading spec {}", src.display()), e))?;
199        let dst_dir = paths::specs_dir(ito_path).join(spec);
200        let dst = dst_dir.join("spec.md");
201        let base = if dst.exists() {
202            Some(
203                ito_common::io::read_to_string_std(&dst)
204                    .map_err(|e| CoreError::io(format!("reading spec {}", dst.display()), e))?,
205            )
206        } else {
207            None
208        };
209        let reconciled = archive_specs::reconcile_spec(base.as_deref(), &delta)?;
210        pending.push((spec.clone(), dst_dir, dst, reconciled));
211    }
212
213    let mut updated = Vec::with_capacity(pending.len());
214    for (spec, dst_dir, dst, reconciled) in pending {
215        match reconciled {
216            Some(markdown) => {
217                ito_common::io::create_dir_all_std(&dst_dir).map_err(|e| {
218                    CoreError::io(format!("creating spec dir {}", dst_dir.display()), e)
219                })?;
220                ito_common::io::write_std(&dst, markdown)
221                    .map_err(|e| CoreError::io(format!("writing spec {}", dst.display()), e))?;
222            }
223            None => {
224                if dst.exists() {
225                    std::fs::remove_file(&dst).map_err(|e| {
226                        CoreError::io(format!("removing retired spec {}", dst.display()), e)
227                    })?;
228                }
229                if dst_dir.exists() {
230                    let mut entries = std::fs::read_dir(&dst_dir).map_err(|e| {
231                        CoreError::io(format!("reading spec dir {}", dst_dir.display()), e)
232                    })?;
233                    if entries
234                        .next()
235                        .transpose()
236                        .map_err(|e| {
237                            CoreError::io(format!("reading spec dir {}", dst_dir.display()), e)
238                        })?
239                        .is_none()
240                    {
241                        std::fs::remove_dir(&dst_dir).map_err(|e| {
242                            CoreError::io(
243                                format!("removing retired spec dir {}", dst_dir.display()),
244                                e,
245                            )
246                        })?;
247                    }
248                }
249            }
250        }
251        updated.push(spec);
252    }
253    Ok(updated)
254}
255
256/// Mark a change complete in local filesystem-backed module markdown.
257pub fn mark_change_complete_in_module_markdown(
258    ito_path: &Path,
259    change_name: &str,
260) -> CoreResult<()> {
261    let Ok(parsed) = parse_change_id(change_name) else {
262        return Ok(());
263    };
264    let module_id = parsed.module_id;
265    let module_repo = FsModuleRepository::new(ito_path);
266    let Some(resolved) =
267        crate::validate::resolve_module(&module_repo, ito_path, module_id.as_str())?
268    else {
269        return Ok(());
270    };
271    let md = ito_common::io::read_to_string_std(&resolved.module_md)
272        .map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
273
274    let mut out = String::new();
275    for line in md.lines() {
276        if line.contains(change_name) {
277            out.push_str(
278                &line
279                    .replacen("- [ ]", "- [x]", 1)
280                    .replacen("* [ ]", "* [x]", 1),
281            );
282            out.push('\n');
283            continue;
284        }
285        out.push_str(line);
286        out.push('\n');
287    }
288    ito_common::io::write_std(&resolved.module_md, out)
289        .map_err(|e| CoreError::io(format!("writing {}", resolved.module_md.display()), e))?;
290    Ok(())
291}
292
293/// Move a change directory to the archive location.
294pub fn move_to_archive(ito_path: &Path, change_name: &str, archive_name: &str) -> CoreResult<()> {
295    let change_dir = paths::change_dir(ito_path, change_name);
296    if !change_dir.exists() {
297        return Err(CoreError::not_found(format!(
298            "Change '{change_name}' not found"
299        )));
300    }
301
302    let archive_root = paths::changes_archive_dir(ito_path);
303    ito_common::io::create_dir_all_std(&archive_root)
304        .map_err(|e| CoreError::io("creating archive directory", e))?;
305
306    let dst = archive_root.join(archive_name);
307    if dst.exists() {
308        return Err(CoreError::validation(format!(
309            "Archive target already exists: {}",
310            dst.display()
311        )));
312    }
313
314    fs::rename(&change_dir, &dst).map_err(|e| CoreError::io("moving change to archive", e))?;
315    Ok(())
316}