1use chrono::{SecondsFormat, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::thread;
16use std::time::Duration;
17
18use ito_common::fs::StdFs;
19use ito_common::id::{parse_change_id, parse_module_id};
20use ito_common::paths;
21
22#[derive(Debug, thiserror::Error)]
23pub enum CreateError {
25 #[error("Invalid module name '{0}'")]
27 InvalidModuleName(String),
28
29 #[error("{0}")]
32 InvalidChangeName(String),
33
34 #[error("Module '{0}' not found")]
36 ModuleNotFound(String),
37
38 #[error("Change '{0}' already exists")]
40 ChangeAlreadyExists(String),
41
42 #[error("I/O error: {0}")]
44 Io(#[from] io::Error),
45
46 #[error("JSON error: {0}")]
48 Json(#[from] serde_json::Error),
49}
50
51#[derive(Debug, Clone)]
52pub struct CreateModuleResult {
54 pub module_id: String,
56 pub module_name: String,
58 pub folder_name: String,
60 pub created: bool,
62 pub module_dir: PathBuf,
64 pub module_md: PathBuf,
66}
67
68#[derive(Debug, Clone)]
69pub struct CreateChangeResult {
71 pub change_id: String,
73 pub change_dir: PathBuf,
75}
76
77pub fn create_module(
82 ito_path: &Path,
83 name: &str,
84 scope: Vec<String>,
85 depends_on: Vec<String>,
86) -> Result<CreateModuleResult, CreateError> {
87 let name = name.trim();
88 if name.is_empty() {
89 return Err(CreateError::InvalidModuleName(name.to_string()));
90 }
91
92 let modules_dir = paths::modules_dir(ito_path);
93 ito_common::io::create_dir_all_std(&modules_dir)?;
94
95 if let Some(existing) = find_module_by_name(&modules_dir, name) {
97 let parsed = parse_module_id(&existing).expect("module folder should be parseable");
99 let module_id = parsed.module_id.to_string();
100 let module_name = parsed.module_name.unwrap_or_else(|| name.to_string());
101 let module_dir = modules_dir.join(&existing);
102 return Ok(CreateModuleResult {
103 module_id,
104 module_name,
105 folder_name: existing,
106 created: false,
107 module_dir: module_dir.clone(),
108 module_md: module_dir.join("module.md"),
109 });
110 }
111
112 let next_id = next_module_id(&modules_dir)?;
113 let folder = format!("{next_id}_{name}");
114 let module_dir = modules_dir.join(&folder);
115 ito_common::io::create_dir_all_std(&module_dir)?;
116
117 let title = to_title_case(name);
118 let md = generate_module_content(
119 &title,
120 Some("<!-- Describe the purpose of this module/epic -->"),
121 &scope,
122 &depends_on,
123 &[],
124 );
125 let module_md = module_dir.join("module.md");
126 ito_common::io::write_std(&module_md, md)?;
127
128 Ok(CreateModuleResult {
129 module_id: next_id,
130 module_name: name.to_string(),
131 folder_name: folder,
132 created: true,
133 module_dir,
134 module_md,
135 })
136}
137
138pub fn create_change(
140 ito_path: &Path,
141 name: &str,
142 schema: &str,
143 module: Option<&str>,
144 description: Option<&str>,
145) -> Result<CreateChangeResult, CreateError> {
146 let name = name.trim();
147 validate_change_name(name)?;
148
149 let modules_dir = paths::modules_dir(ito_path);
150 let module_id = module
151 .and_then(|m| parse_module_id(m).ok().map(|p| p.module_id.to_string()))
152 .unwrap_or_else(|| "000".to_string());
153
154 if !modules_dir.exists() {
156 ito_common::io::create_dir_all_std(&modules_dir)?;
157 }
158 if !module_exists(&modules_dir, &module_id) {
159 if module_id == "000" {
160 create_ungrouped_module(ito_path)?;
161 } else {
162 return Err(CreateError::ModuleNotFound(module_id));
163 }
164 }
165
166 let next_num = allocate_next_change_number(ito_path, &module_id)?;
167 let folder = format!("{module_id}-{next_num:02}_{name}");
168
169 let changes_dir = paths::changes_dir(ito_path);
170 ito_common::io::create_dir_all_std(&changes_dir)?;
171 let change_dir = changes_dir.join(&folder);
172 if change_dir.exists() {
173 return Err(CreateError::ChangeAlreadyExists(folder));
174 }
175 ito_common::io::create_dir_all_std(&change_dir)?;
176
177 write_change_metadata(&change_dir, schema)?;
178
179 if let Some(desc) = description {
180 let readme = format!("# {folder}\n\n{desc}\n");
182 ito_common::io::write_std(&change_dir.join("README.md"), readme)?;
183 }
184
185 add_change_to_module(ito_path, &module_id, &folder)?;
186
187 Ok(CreateChangeResult {
188 change_id: folder,
189 change_dir,
190 })
191}
192
193fn write_change_metadata(change_dir: &Path, schema: &str) -> Result<(), CreateError> {
194 let created = Utc::now().format("%Y-%m-%d").to_string();
195 let content = format!("schema: {schema}\ncreated: {created}\n");
196 ito_common::io::write_std(&change_dir.join(".ito.yaml"), content)?;
197 Ok(())
198}
199
200fn allocate_next_change_number(ito_path: &Path, module_id: &str) -> Result<u32, CreateError> {
201 let state_dir = ito_path.join("workflows").join(".state");
203 ito_common::io::create_dir_all_std(&state_dir)?;
204 let lock_path = state_dir.join("change-allocations.lock");
205 let state_path = state_dir.join("change-allocations.json");
206
207 let lock = acquire_lock(&lock_path)?;
208 let mut state: AllocationState = if state_path.exists() {
209 serde_json::from_str(&ito_common::io::read_to_string_std(&state_path)?)?
210 } else {
211 AllocationState::default()
212 };
213
214 let mut max_seen: u32 = 0;
215 let changes_dir = paths::changes_dir(ito_path);
216 max_seen = max_seen.max(max_change_num_in_dir(&changes_dir, module_id));
217 max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
218 &paths::changes_archive_dir(ito_path),
219 module_id,
220 ));
221 max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
222 &paths::archive_changes_dir(ito_path),
223 module_id,
224 ));
225
226 max_seen = max_seen.max(max_change_num_in_module_md(ito_path, module_id)?);
227 if let Some(ms) = state.modules.get(module_id) {
228 max_seen = max_seen.max(ms.last_change_num);
229 }
230
231 let next = max_seen + 1;
232 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
233 state.modules.insert(
234 module_id.to_string(),
235 ModuleAllocationState {
236 last_change_num: next,
237 updated_at,
238 },
239 );
240
241 ito_common::io::write_std(&state_path, serde_json::to_string_pretty(&state)?)?;
242
243 drop(lock);
244 let _ = fs::remove_file(&lock_path);
245
246 Ok(next)
247}
248
249fn acquire_lock(path: &Path) -> Result<fs::File, CreateError> {
250 for _ in 0..10 {
251 match fs::OpenOptions::new()
252 .write(true)
253 .create_new(true)
254 .open(path)
255 {
256 Ok(f) => return Ok(f),
257 Err(_) => thread::sleep(Duration::from_millis(50)),
258 }
259 }
260 Ok(fs::OpenOptions::new()
262 .write(true)
263 .create_new(true)
264 .open(path)?)
265}
266
267#[derive(Debug, Serialize, Deserialize, Default)]
268struct AllocationState {
269 #[serde(default)]
270 modules: HashMap<String, ModuleAllocationState>,
271}
272
273#[derive(Debug, Serialize, Deserialize, Clone)]
274#[serde(rename_all = "camelCase")]
275struct ModuleAllocationState {
276 last_change_num: u32,
277 updated_at: String,
278}
279
280fn max_change_num_in_dir(dir: &Path, module_id: &str) -> u32 {
281 let mut max_seen = 0;
282 let fs = StdFs;
283 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, dir) else {
284 return 0;
285 };
286 for name in entries {
287 if name == "archive" {
288 continue;
289 }
290 if let Ok(parsed) = parse_change_id(&name)
291 && parsed.module_id.as_str() == module_id
292 && let Ok(n) = parsed.change_num.parse::<u32>()
293 {
294 max_seen = max_seen.max(n);
295 }
296 }
297 max_seen
298}
299
300fn max_change_num_in_archived_change_dirs(archive_dir: &Path, module_id: &str) -> u32 {
301 let mut max_seen = 0;
302 let fs = StdFs;
303 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, archive_dir) else {
304 return 0;
305 };
306 for name in entries {
307 if name.len() <= 11 {
309 continue;
310 }
311 let change_part = &name[11..];
313 if let Ok(parsed) = parse_change_id(change_part)
314 && parsed.module_id.as_str() == module_id
315 && let Ok(n) = parsed.change_num.parse::<u32>()
316 {
317 max_seen = max_seen.max(n);
318 }
319 }
320 max_seen
321}
322
323fn find_module_by_name(modules_dir: &Path, name: &str) -> Option<String> {
324 let fs = StdFs;
325 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
326 return None;
327 };
328 for folder in entries {
329 if let Ok(parsed) = parse_module_id(&folder)
330 && parsed.module_name.as_deref() == Some(name)
331 {
332 return Some(folder);
333 }
334 }
335 None
336}
337
338fn module_exists(modules_dir: &Path, module_id: &str) -> bool {
339 let fs = StdFs;
340 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
341 return false;
342 };
343 for folder in entries {
344 if let Ok(parsed) = parse_module_id(&folder)
345 && parsed.module_id.as_str() == module_id
346 {
347 return true;
348 }
349 }
350 false
351}
352
353fn next_module_id(modules_dir: &Path) -> Result<String, CreateError> {
354 let mut max_seen: u32 = 0;
355 let fs = StdFs;
356 if let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) {
357 for folder in entries {
358 if let Ok(parsed) = parse_module_id(&folder)
359 && let Ok(n) = parsed.module_id.as_str().parse::<u32>()
360 {
361 max_seen = max_seen.max(n);
362 }
363 }
364 }
365 Ok(format!("{n:03}", n = max_seen + 1))
366}
367
368fn validate_change_name(name: &str) -> Result<(), CreateError> {
369 if name.is_empty() {
371 return Err(CreateError::InvalidChangeName(
372 "Change name cannot be empty".to_string(),
373 ));
374 }
375 if name.chars().any(|c| c.is_ascii_uppercase()) {
376 return Err(CreateError::InvalidChangeName(
377 "Change name must be lowercase (use kebab-case)".to_string(),
378 ));
379 }
380 if name.chars().any(|c| c.is_whitespace()) {
381 return Err(CreateError::InvalidChangeName(
382 "Change name cannot contain spaces (use hyphens instead)".to_string(),
383 ));
384 }
385 if name.contains('_') {
386 return Err(CreateError::InvalidChangeName(
387 "Change name cannot contain underscores (use hyphens instead)".to_string(),
388 ));
389 }
390 if name.starts_with('-') {
391 return Err(CreateError::InvalidChangeName(
392 "Change name cannot start with a hyphen".to_string(),
393 ));
394 }
395 if name.ends_with('-') {
396 return Err(CreateError::InvalidChangeName(
397 "Change name cannot end with a hyphen".to_string(),
398 ));
399 }
400 if name.contains("--") {
401 return Err(CreateError::InvalidChangeName(
402 "Change name cannot contain consecutive hyphens".to_string(),
403 ));
404 }
405 if name
406 .chars()
407 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
408 {
409 return Err(CreateError::InvalidChangeName(
410 "Change name can only contain lowercase letters, numbers, and hyphens".to_string(),
411 ));
412 }
413 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
414 return Err(CreateError::InvalidChangeName(
415 "Change name must start with a letter".to_string(),
416 ));
417 }
418
419 let mut parts = name.split('-');
421 let Some(first) = parts.next() else {
422 return Err(CreateError::InvalidChangeName(
423 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
424 .to_string(),
425 ));
426 };
427 if first.is_empty() {
428 return Err(CreateError::InvalidChangeName(
429 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
430 .to_string(),
431 ));
432 }
433 let mut chars = first.chars();
434 if !chars.next().is_some_and(|c| c.is_ascii_lowercase()) {
435 return Err(CreateError::InvalidChangeName(
436 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
437 .to_string(),
438 ));
439 }
440 if chars.any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit())) {
441 return Err(CreateError::InvalidChangeName(
442 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
443 .to_string(),
444 ));
445 }
446 for part in parts {
447 if part.is_empty() {
448 return Err(CreateError::InvalidChangeName(
449 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
450 .to_string(),
451 ));
452 }
453 if part
454 .chars()
455 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit()))
456 {
457 return Err(CreateError::InvalidChangeName(
458 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
459 .to_string(),
460 ));
461 }
462 }
463
464 Ok(())
465}
466
467fn to_title_case(kebab: &str) -> String {
468 kebab
469 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
470 .filter(|s| !s.is_empty())
471 .map(|w| {
472 let mut cs = w.chars();
473 match cs.next() {
474 None => String::new(),
475 Some(first) => {
476 let mut out = String::new();
477 out.push(first.to_ascii_uppercase());
478 out.push_str(&cs.as_str().to_ascii_lowercase());
479 out
480 }
481 }
482 })
483 .collect::<Vec<_>>()
484 .join(" ")
485}
486
487#[derive(Debug, Clone)]
488struct ModuleChange {
489 id: String,
490 completed: bool,
491 planned: bool,
492}
493
494fn add_change_to_module(
495 ito_path: &Path,
496 module_id: &str,
497 change_id: &str,
498) -> Result<(), CreateError> {
499 let modules_dir = paths::modules_dir(ito_path);
500 let module_folder = find_module_by_id(&modules_dir, module_id)
501 .ok_or_else(|| CreateError::ModuleNotFound(module_id.to_string()))?;
502 let module_md = modules_dir.join(&module_folder).join("module.md");
503 let existing = ito_common::io::read_to_string_std(&module_md)?;
504
505 let title = extract_title(&existing)
506 .or_else(|| module_folder.split('_').nth(1).map(to_title_case))
507 .unwrap_or_else(|| "Module".to_string());
508 let purpose = extract_section(&existing, "Purpose")
509 .map(|s| s.trim().to_string())
510 .filter(|s| !s.is_empty());
511 let scope = parse_bullets(&extract_section(&existing, "Scope").unwrap_or_default());
512 let depends_on = parse_bullets(&extract_section(&existing, "Depends On").unwrap_or_default());
513 let mut changes = parse_changes(&extract_section(&existing, "Changes").unwrap_or_default());
514
515 if !changes.iter().any(|c| c.id == change_id) {
516 changes.push(ModuleChange {
517 id: change_id.to_string(),
518 completed: false,
519 planned: false,
520 });
521 }
522
523 let md = generate_module_content(&title, purpose.as_deref(), &scope, &depends_on, &changes);
524 ito_common::io::write_std(&module_md, md)?;
525 Ok(())
526}
527
528fn find_module_by_id(modules_dir: &Path, module_id: &str) -> Option<String> {
529 let fs = StdFs;
530 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
531 return None;
532 };
533 for folder in entries {
534 if let Ok(parsed) = parse_module_id(&folder)
535 && parsed.module_id.as_str() == module_id
536 {
537 return Some(folder);
538 }
539 }
540 None
541}
542
543fn max_change_num_in_module_md(ito_path: &Path, module_id: &str) -> Result<u32, CreateError> {
544 let modules_dir = paths::modules_dir(ito_path);
545 let Some(folder) = find_module_by_id(&modules_dir, module_id) else {
546 return Ok(0);
547 };
548 let module_md = modules_dir.join(folder).join("module.md");
549 let content = ito_common::io::read_to_string_or_default(&module_md);
550 let mut max_seen: u32 = 0;
551 for token in content.split_whitespace() {
552 if let Ok(parsed) = parse_change_id(
553 token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'),
554 ) && parsed.module_id.as_str() == module_id
555 && let Ok(n) = parsed.change_num.parse::<u32>()
556 {
557 max_seen = max_seen.max(n);
558 }
559 }
560 Ok(max_seen)
561}
562
563fn extract_title(markdown: &str) -> Option<String> {
564 for line in markdown.lines() {
565 let line = line.trim();
566 if let Some(rest) = line.strip_prefix("# ") {
567 return Some(rest.trim().to_string());
568 }
569 }
570 None
571}
572
573fn extract_section(markdown: &str, header: &str) -> Option<String> {
574 let needle = format!("## {header}");
575 let mut in_section = false;
576 let mut out: Vec<&str> = Vec::new();
577 for line in markdown.lines() {
578 if line.trim() == needle {
579 in_section = true;
580 continue;
581 }
582 if in_section {
583 if line.trim_start().starts_with("## ") {
584 break;
585 }
586 out.push(line);
587 }
588 }
589 if !in_section {
590 return None;
591 }
592 Some(out.join("\n"))
593}
594
595fn parse_bullets(section: &str) -> Vec<String> {
596 let mut items = Vec::new();
597 for line in section.lines() {
598 let t = line.trim();
599 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
600 let s = rest.trim();
601 if !s.is_empty() {
602 items.push(s.to_string());
603 }
604 }
605 }
606 items
607}
608
609fn parse_changes(section: &str) -> Vec<ModuleChange> {
610 let mut out = Vec::new();
611 for line in section.lines() {
612 let t = line.trim();
613 if let Some(rest) = t.strip_prefix("- [") {
614 if rest.len() < 3 {
616 continue;
617 }
618 let checked = rest.chars().next().unwrap_or(' ');
619 let completed = checked == 'x' || checked == 'X';
620 let after = rest[3..].trim();
621 let mut parts = after.split_whitespace();
622 let Some(id) = parts.next() else {
623 continue;
624 };
625 let planned = after.contains("(planned)");
626 out.push(ModuleChange {
627 id: id.to_string(),
628 completed,
629 planned,
630 });
631 continue;
632 }
633 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
634 let rest = rest.trim();
635 if rest.is_empty() {
636 continue;
637 }
638 let id = rest.split_whitespace().next().unwrap_or("");
639 if id.is_empty() {
640 continue;
641 }
642 let planned = rest.contains("(planned)");
643 out.push(ModuleChange {
644 id: id.to_string(),
645 completed: false,
646 planned,
647 });
648 }
649 }
650 out
651}
652
653fn generate_module_content<T: AsRef<str>>(
654 title: &str,
655 purpose: Option<&str>,
656 scope: &[T],
657 depends_on: &[T],
658 changes: &[ModuleChange],
659) -> String {
660 let purpose = purpose
661 .map(|s| s.to_string())
662 .unwrap_or_else(|| "<!-- Describe the purpose of this module/epic -->".to_string());
663 let scope_section = if scope.is_empty() {
664 "<!-- List the scope of this module -->".to_string()
665 } else {
666 scope
667 .iter()
668 .map(|s| format!("- {}", s.as_ref()))
669 .collect::<Vec<_>>()
670 .join("\n")
671 };
672 let changes_section = if changes.is_empty() {
673 "<!-- Changes will be listed here as they are created -->".to_string()
674 } else {
675 changes
676 .iter()
677 .map(|c| {
678 let check = if c.completed { "x" } else { " " };
679 let planned = if c.planned { " (planned)" } else { "" };
680 format!("- [{check}] {}{planned}", c.id)
681 })
682 .collect::<Vec<_>>()
683 .join("\n")
684 };
685
686 let mut out = String::new();
690 out.push_str(&format!("# {title}\n\n"));
691
692 out.push_str("## Purpose\n");
693 out.push_str(&purpose);
694 out.push_str("\n\n");
695
696 out.push_str("## Scope\n");
697 out.push_str(&scope_section);
698 out.push_str("\n\n");
699
700 if !depends_on.is_empty() {
701 let depends_section = depends_on
702 .iter()
703 .map(|s| format!("- {}", s.as_ref()))
704 .collect::<Vec<_>>()
705 .join("\n");
706 out.push_str("## Depends On\n");
707 out.push_str(&depends_section);
708 out.push_str("\n\n");
709 }
710
711 out.push_str("## Changes\n");
712 out.push_str(&changes_section);
713 out.push('\n');
714 out
715}
716
717fn create_ungrouped_module(ito_path: &Path) -> Result<(), CreateError> {
718 let modules_dir = paths::modules_dir(ito_path);
719 ito_common::io::create_dir_all_std(&modules_dir)?;
720 let dir = modules_dir.join("000_ungrouped");
721 ito_common::io::create_dir_all_std(&dir)?;
722 let empty: [&str; 0] = [];
723 let md = generate_module_content(
724 "Ungrouped",
725 Some("Changes that do not belong to a specific module."),
726 &["*"],
727 &empty,
728 &[],
729 );
730 ito_common::io::write_std(&dir.join("module.md"), md)?;
731 Ok(())
732}