1use 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum TaskStatus {
24 NoTasks,
26 AllComplete,
28 HasIncomplete {
30 pending: usize,
32 total: usize,
34 },
35}
36
37pub fn check_task_completion(contents: &str) -> TaskStatus {
42 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 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
96pub 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
102pub 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
113pub 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
119pub 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
125pub 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
156pub 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
171pub 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
200pub fn mark_change_complete_in_module_markdown(
202 ito_path: &Path,
203 change_name: &str,
204) -> CoreResult<()> {
205 let Ok(parsed) = parse_change_id(change_name) else {
206 return Ok(());
207 };
208 let module_id = parsed.module_id;
209 let module_repo = FsModuleRepository::new(ito_path);
210 let Some(resolved) =
211 crate::validate::resolve_module(&module_repo, ito_path, module_id.as_str())?
212 else {
213 return Ok(());
214 };
215 let md = ito_common::io::read_to_string_std(&resolved.module_md)
216 .map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
217
218 let mut out = String::new();
219 for line in md.lines() {
220 if line.contains(change_name) {
221 out.push_str(
222 &line
223 .replacen("- [ ]", "- [x]", 1)
224 .replacen("* [ ]", "* [x]", 1),
225 );
226 out.push('\n');
227 continue;
228 }
229 out.push_str(line);
230 out.push('\n');
231 }
232 ito_common::io::write_std(&resolved.module_md, out)
233 .map_err(|e| CoreError::io(format!("writing {}", resolved.module_md.display()), e))?;
234 Ok(())
235}
236
237pub fn move_to_archive(ito_path: &Path, change_name: &str, archive_name: &str) -> CoreResult<()> {
239 let change_dir = paths::change_dir(ito_path, change_name);
240 if !change_dir.exists() {
241 return Err(CoreError::not_found(format!(
242 "Change '{change_name}' not found"
243 )));
244 }
245
246 let archive_root = paths::changes_archive_dir(ito_path);
247 ito_common::io::create_dir_all_std(&archive_root)
248 .map_err(|e| CoreError::io("creating archive directory", e))?;
249
250 let dst = archive_root.join(archive_name);
251 if dst.exists() {
252 return Err(CoreError::validation(format!(
253 "Archive target already exists: {}",
254 dst.display()
255 )));
256 }
257
258 fs::rename(&change_dir, &dst).map_err(|e| CoreError::io("moving change to archive", e))?;
259 Ok(())
260}