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#[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)]
32pub enum TaskStatus {
34 NoTasks,
36 AllComplete,
38 HasIncomplete {
40 pending: usize,
42 total: usize,
44 },
45}
46
47pub fn check_task_completion(contents: &str) -> TaskStatus {
52 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 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
106pub 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
112pub 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
123pub 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
129pub 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
135pub 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
166pub 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
181pub 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
256pub 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
293pub 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}