1use chrono::{SecondsFormat, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
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, parse_sub_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("Sub-module '{0}' not found")]
40 SubModuleNotFound(String),
41
42 #[error("{0}")]
44 MutuallyExclusive(String),
45
46 #[error("Change '{0}' already exists")]
48 ChangeAlreadyExists(String),
49
50 #[error("Sub-module '{0}' already exists under module '{1}'")]
52 DuplicateSubModuleName(String, String),
53
54 #[error("Sub-module number exhausted under module '{0}'; maximum of 99 sub-modules allowed")]
56 SubModuleNumberExhausted(String),
57
58 #[error(
60 "Cannot acquire lock: {path}\n\
61 \n\
62 A previous `ito create` may have been interrupted, leaving a stale lock file.\n\
63 Fix: delete the lock file and retry:\n\
64 \n\
65 \x20 rm {path}\n"
66 )]
67 LockAcquireFailed {
68 path: String,
70 },
71
72 #[error("I/O error: {0}")]
74 Io(#[from] io::Error),
75
76 #[error("JSON error: {0}")]
78 Json(#[from] serde_json::Error),
79}
80
81#[derive(Debug, Clone)]
82pub struct CreateModuleResult {
84 pub module_id: String,
86 pub module_name: String,
88 pub folder_name: String,
90 pub created: bool,
92 pub module_dir: PathBuf,
94 pub module_md: PathBuf,
96}
97
98#[derive(Debug, Clone)]
99pub struct CreateChangeResult {
101 pub change_id: String,
103 pub change_dir: PathBuf,
105}
106
107#[derive(Debug, Clone)]
108pub struct CreateSubModuleResult {
110 pub sub_module_id: String,
112 pub sub_module_name: String,
114 pub parent_module_id: String,
116 pub sub_module_dir: PathBuf,
118}
119
120pub fn create_module(
125 ito_path: &Path,
126 name: &str,
127 scope: Vec<String>,
128 depends_on: Vec<String>,
129 description: Option<&str>,
130) -> Result<CreateModuleResult, CreateError> {
131 let name = name.trim();
132 if name.is_empty() {
133 return Err(CreateError::InvalidModuleName(name.to_string()));
134 }
135
136 let modules_dir = paths::modules_dir(ito_path);
137 ito_common::io::create_dir_all_std(&modules_dir)?;
138
139 if let Some(existing) = find_module_by_name(&modules_dir, name) {
141 let parsed = parse_module_id(&existing).expect("module folder should be parseable");
143 let module_id = parsed.module_id.to_string();
144 let module_name = parsed.module_name.unwrap_or_else(|| name.to_string());
145 let module_dir = modules_dir.join(&existing);
146 return Ok(CreateModuleResult {
147 module_id,
148 module_name,
149 folder_name: existing,
150 created: false,
151 module_dir: module_dir.clone(),
152 module_md: module_dir.join("module.md"),
153 });
154 }
155
156 let next_id = next_module_id(&modules_dir)?;
157 let folder = format!("{next_id}_{name}");
158 let module_dir = modules_dir.join(&folder);
159 ito_common::io::create_dir_all_std(&module_dir)?;
160
161 let title = to_title_case(name);
162 let md = generate_module_content(
163 &title,
164 description.or(Some("<!-- Describe the purpose of this module/epic -->")),
165 &scope,
166 &depends_on,
167 &[],
168 );
169 let module_md = module_dir.join("module.md");
170 ito_common::io::write_std(&module_md, md)?;
171
172 Ok(CreateModuleResult {
173 module_id: next_id,
174 module_name: name.to_string(),
175 folder_name: folder,
176 created: true,
177 module_dir,
178 module_md,
179 })
180}
181
182pub fn create_change(
191 ito_path: &Path,
192 name: &str,
193 schema: &str,
194 module: Option<&str>,
195 description: Option<&str>,
196) -> Result<CreateChangeResult, CreateError> {
197 create_change_inner(ito_path, name, schema, module, None, description)
198}
199
200pub fn create_change_in_sub_module(
206 ito_path: &Path,
207 name: &str,
208 schema: &str,
209 sub_module: &str,
210 description: Option<&str>,
211) -> Result<CreateChangeResult, CreateError> {
212 create_change_inner(ito_path, name, schema, None, Some(sub_module), description)
213}
214
215pub fn create_sub_module(
225 ito_path: &Path,
226 name: &str,
227 parent_module: &str,
228 description: Option<&str>,
229) -> Result<CreateSubModuleResult, CreateError> {
230 let name = name.trim();
231 validate_change_name(name)?;
233
234 let modules_dir = paths::modules_dir(ito_path);
235
236 let parent_id = parse_module_id(parent_module)
238 .ok()
239 .map(|p| p.module_id.to_string())
240 .unwrap_or_else(|| parent_module.to_string());
241
242 let parent_folder = find_module_by_id(&modules_dir, &parent_id)
244 .ok_or_else(|| CreateError::ModuleNotFound(parent_id.clone()))?;
245
246 let parent_dir = modules_dir.join(&parent_folder);
247 let sub_dir = parent_dir.join("sub");
248 ito_common::io::create_dir_all_std(&sub_dir)?;
249
250 let fs = ito_common::fs::StdFs;
252 if let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, &sub_dir) {
253 for entry in &entries {
254 if let Some((_, entry_name)) = entry.split_once('_')
255 && entry_name == name
256 {
257 return Err(CreateError::DuplicateSubModuleName(
258 name.to_string(),
259 parent_id.clone(),
260 ));
261 }
262 }
263 }
264
265 let next_sub_num = next_sub_module_num(&sub_dir)?;
267 if next_sub_num >= 100 {
268 return Err(CreateError::SubModuleNumberExhausted(parent_id));
269 }
270 let folder_name = format!("{next_sub_num:02}_{name}");
271 let sub_module_dir = sub_dir.join(&folder_name);
272 ito_common::io::create_dir_all_std(&sub_module_dir)?;
273
274 let sub_module_id = format!("{parent_id}.{next_sub_num:02}");
276
277 let title = to_title_case(name);
279 let md = generate_module_content(
280 &title,
281 description.or(Some("<!-- Describe the purpose of this sub-module -->")),
282 &["*"],
283 &[] as &[&str],
284 &[],
285 );
286 ito_common::io::write_std(&sub_module_dir.join("module.md"), md)?;
287
288 Ok(CreateSubModuleResult {
289 sub_module_id,
290 sub_module_name: name.to_string(),
291 parent_module_id: parent_id,
292 sub_module_dir,
293 })
294}
295
296fn next_sub_module_num(sub_dir: &Path) -> Result<u32, CreateError> {
298 let mut max_seen: u32 = 0;
299 let fs = ito_common::fs::StdFs;
300 if let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, sub_dir) {
301 for entry in entries {
302 if let Some((num_str, _)) = entry.split_once('_')
303 && let Ok(n) = num_str.parse::<u32>()
304 {
305 max_seen = max_seen.max(n);
306 }
307 }
308 }
309 Ok(max_seen + 1)
310}
311
312fn create_change_inner(
313 ito_path: &Path,
314 name: &str,
315 schema: &str,
316 module: Option<&str>,
317 sub_module: Option<&str>,
318 description: Option<&str>,
319) -> Result<CreateChangeResult, CreateError> {
320 let name = name.trim();
321 validate_change_name(name)?;
322
323 let modules_dir = paths::modules_dir(ito_path);
324
325 if !modules_dir.exists() {
327 ito_common::io::create_dir_all_std(&modules_dir)?;
328 }
329
330 let (namespace_key, folder_prefix, checklist_target) = if let Some(sm) = sub_module {
334 let parsed = parse_sub_module_id(sm).map_err(|e| {
335 CreateError::InvalidChangeName(format!("Invalid sub-module id '{sm}': {}", e.error))
336 })?;
337 let parent_id = parsed.parent_module_id.as_str().to_string();
338 let sub_id = parsed.sub_module_id.as_str().to_string();
339
340 if !module_exists(&modules_dir, &parent_id) {
342 return Err(CreateError::ModuleNotFound(parent_id));
343 }
344
345 if !sub_module_exists(&modules_dir, &parent_id, &parsed.sub_num) {
347 return Err(CreateError::SubModuleNotFound(sub_id.clone()));
348 }
349
350 (
351 sub_id.clone(),
352 sub_id,
353 ChecklistTarget::SubModule(sm.to_string()),
354 )
355 } else {
356 let module_id = module
357 .and_then(|m| parse_module_id(m).ok().map(|p| p.module_id.to_string()))
358 .unwrap_or_else(|| "000".to_string());
359
360 if !module_exists(&modules_dir, &module_id) {
361 if module_id == "000" {
362 create_ungrouped_module(ito_path)?;
363 } else {
364 return Err(CreateError::ModuleNotFound(module_id.clone()));
365 }
366 }
367
368 (
369 module_id.clone(),
370 module_id.clone(),
371 ChecklistTarget::Module(module_id),
372 )
373 };
374
375 let next_num = allocate_next_change_number(ito_path, &namespace_key)?;
376 let folder = format!("{folder_prefix}-{next_num:02}_{name}");
377
378 let changes_dir = paths::changes_dir(ito_path);
379 ito_common::io::create_dir_all_std(&changes_dir)?;
380 let change_dir = changes_dir.join(&folder);
381 if change_dir.exists() {
382 return Err(CreateError::ChangeAlreadyExists(folder));
383 }
384 ito_common::io::create_dir_all_std(&change_dir)?;
385
386 write_change_metadata(&change_dir, schema)?;
387
388 if let Some(desc) = description {
389 let readme = format!("# {folder}\n\n{desc}\n");
391 ito_common::io::write_std(&change_dir.join("README.md"), readme)?;
392 }
393
394 match checklist_target {
395 ChecklistTarget::Module(module_id) => {
396 add_change_to_module(ito_path, &module_id, &folder)?;
397 }
398 ChecklistTarget::SubModule(sub_module_id) => {
399 add_change_to_sub_module(ito_path, &sub_module_id, &folder)?;
400 }
401 }
402
403 Ok(CreateChangeResult {
404 change_id: folder,
405 change_dir,
406 })
407}
408
409enum ChecklistTarget {
411 Module(String),
413 SubModule(String),
415}
416
417fn write_change_metadata(change_dir: &Path, schema: &str) -> Result<(), CreateError> {
418 let created = Utc::now().format("%Y-%m-%d").to_string();
419 let content = format!("schema: {schema}\ncreated: {created}\n");
420 ito_common::io::write_std(&change_dir.join(".ito.yaml"), content)?;
421 Ok(())
422}
423
424fn allocate_next_change_number(ito_path: &Path, namespace_key: &str) -> Result<u32, CreateError> {
425 let state_dir = ito_path.join("workflows").join(".state");
427 ito_common::io::create_dir_all_std(&state_dir)?;
428 let lock_path = state_dir.join("change-allocations.lock");
429 let state_path = state_dir.join("change-allocations.json");
430
431 let lock = acquire_lock(&lock_path)?;
432 let mut state: AllocationState = if state_path.exists() {
433 serde_json::from_str(&ito_common::io::read_to_string_std(&state_path)?)?
434 } else {
435 AllocationState::default()
436 };
437
438 let mut max_seen: u32 = 0;
439 let changes_dir = paths::changes_dir(ito_path);
440 max_seen = max_seen.max(max_change_num_in_dir(&changes_dir, namespace_key));
441 max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
442 &paths::changes_archive_dir(ito_path),
443 namespace_key,
444 ));
445 max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
446 &paths::archive_changes_dir(ito_path),
447 namespace_key,
448 ));
449
450 if namespace_key.contains('.') {
453 max_seen = max_seen.max(max_change_num_in_sub_module_md(ito_path, namespace_key)?);
454 } else {
455 max_seen = max_seen.max(max_change_num_in_module_md(ito_path, namespace_key)?);
456 }
457 if let Some(ms) = state.modules.get(namespace_key) {
458 max_seen = max_seen.max(ms.last_change_num);
459 }
460
461 let next = max_seen + 1;
462 let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
463 state.modules.insert(
464 namespace_key.to_string(),
465 ModuleAllocationState {
466 last_change_num: next,
467 updated_at,
468 },
469 );
470
471 ito_common::io::write_std(&state_path, serde_json::to_string_pretty(&state)?)?;
472
473 drop(lock);
474 let _ = fs::remove_file(&lock_path);
475
476 Ok(next)
477}
478
479const LOCK_STALE_SECS: u64 = 30;
481
482fn acquire_lock(path: &Path) -> Result<fs::File, CreateError> {
483 for attempt in 0..10 {
484 match fs::OpenOptions::new()
485 .write(true)
486 .create_new(true)
487 .open(path)
488 {
489 Ok(f) => return Ok(f),
490 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
491 if let Ok(meta) = fs::metadata(path)
493 && let Ok(modified) = meta.modified()
494 && let Ok(age) = modified.elapsed()
495 && age.as_secs() >= LOCK_STALE_SECS
496 {
497 let _ = fs::remove_file(path);
498 continue;
500 }
501 if attempt < 9 {
503 thread::sleep(Duration::from_millis(50));
504 }
505 }
506 Err(e) => return Err(CreateError::Io(e)),
507 }
508 }
509 Err(CreateError::LockAcquireFailed {
511 path: path.display().to_string(),
512 })
513}
514
515#[derive(Debug, Serialize, Deserialize, Default)]
516struct AllocationState {
517 #[serde(default)]
518 modules: BTreeMap<String, ModuleAllocationState>,
519}
520
521#[derive(Debug, Serialize, Deserialize, Clone)]
522#[serde(rename_all = "camelCase")]
523struct ModuleAllocationState {
524 last_change_num: u32,
525 updated_at: String,
526}
527
528fn change_belongs_to_namespace(
535 parsed: &ito_common::id::ParsedChangeId,
536 namespace_key: &str,
537) -> bool {
538 if namespace_key.contains('.') {
539 parsed
541 .sub_module_id
542 .as_ref()
543 .map(|s| s.as_str() == namespace_key)
544 .unwrap_or(false)
545 } else {
546 parsed.module_id.as_str() == namespace_key && parsed.sub_module_id.is_none()
548 }
549}
550
551fn max_change_num_in_dir(dir: &Path, namespace_key: &str) -> u32 {
552 let mut max_seen = 0;
553 let fs = StdFs;
554 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, dir) else {
555 return 0;
556 };
557 for name in entries {
558 if name == "archive" {
559 continue;
560 }
561 if let Ok(parsed) = parse_change_id(&name)
562 && change_belongs_to_namespace(&parsed, namespace_key)
563 && let Ok(n) = parsed.change_num.parse::<u32>()
564 {
565 max_seen = max_seen.max(n);
566 }
567 }
568 max_seen
569}
570
571fn max_change_num_in_archived_change_dirs(archive_dir: &Path, namespace_key: &str) -> u32 {
572 let mut max_seen = 0;
573 let fs = StdFs;
574 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, archive_dir) else {
575 return 0;
576 };
577 for name in entries {
578 if name.len() <= 11 {
580 continue;
581 }
582 let change_part = &name[11..];
584 if let Ok(parsed) = parse_change_id(change_part)
585 && change_belongs_to_namespace(&parsed, namespace_key)
586 && let Ok(n) = parsed.change_num.parse::<u32>()
587 {
588 max_seen = max_seen.max(n);
589 }
590 }
591 max_seen
592}
593
594fn find_module_by_name(modules_dir: &Path, name: &str) -> Option<String> {
595 let fs = StdFs;
596 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
597 return None;
598 };
599 for folder in entries {
600 if let Ok(parsed) = parse_module_id(&folder)
601 && parsed.module_name.as_deref() == Some(name)
602 {
603 return Some(folder);
604 }
605 }
606 None
607}
608
609fn module_exists(modules_dir: &Path, module_id: &str) -> bool {
610 let fs = StdFs;
611 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
612 return false;
613 };
614 for folder in entries {
615 if let Ok(parsed) = parse_module_id(&folder)
616 && parsed.module_id.as_str() == module_id
617 {
618 return true;
619 }
620 }
621 false
622}
623
624fn sub_module_exists(modules_dir: &Path, parent_module_id: &str, sub_num: &str) -> bool {
626 let Some(parent_folder) = find_module_by_id(modules_dir, parent_module_id) else {
627 return false;
628 };
629 let sub_dir = modules_dir.join(&parent_folder).join("sub");
630 if !sub_dir.exists() {
631 return false;
632 }
633 let fs = StdFs;
634 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, &sub_dir) else {
635 return false;
636 };
637 let prefix = format!("{sub_num}_");
638 entries.iter().any(|e| e.starts_with(&prefix))
639}
640
641fn find_sub_module_dir(modules_dir: &Path, sub_module_id: &str) -> Option<std::path::PathBuf> {
646 let parsed = parse_sub_module_id(sub_module_id).ok()?;
647 let parent_id = parsed.parent_module_id.as_str();
648 let sub_num = &parsed.sub_num;
649
650 let parent_folder = find_module_by_id(modules_dir, parent_id)?;
651 let sub_dir = modules_dir.join(&parent_folder).join("sub");
652
653 let fs = StdFs;
654 let entries = ito_domain::discovery::list_dir_names(&fs, &sub_dir).ok()?;
655 let prefix = format!("{sub_num}_");
656 let sub_folder = entries.into_iter().find(|e| e.starts_with(&prefix))?;
657 Some(sub_dir.join(sub_folder))
658}
659
660fn add_change_to_sub_module(
665 ito_path: &Path,
666 sub_module_id: &str,
667 change_id: &str,
668) -> Result<(), CreateError> {
669 let modules_dir = paths::modules_dir(ito_path);
670 let sub_module_dir = find_sub_module_dir(&modules_dir, sub_module_id)
671 .ok_or_else(|| CreateError::SubModuleNotFound(sub_module_id.to_string()))?;
672
673 let module_md = sub_module_dir.join("module.md");
674
675 let existing = if module_md.exists() {
677 ito_common::io::read_to_string_std(&module_md)?
678 } else {
679 let parsed = parse_sub_module_id(sub_module_id).map_err(|e| {
680 CreateError::InvalidChangeName(format!(
681 "Invalid sub-module id '{sub_module_id}': {}",
682 e.error
683 ))
684 })?;
685 let title = to_title_case(parsed.sub_name.as_deref().unwrap_or(sub_module_id));
686 generate_module_content(&title, None, &["*"], &[] as &[&str], &[])
687 };
688
689 let title = extract_title(&existing)
690 .or_else(|| {
691 sub_module_dir
692 .file_name()
693 .and_then(|n| n.to_str())
694 .and_then(|n| n.split_once('_').map(|(_, name)| to_title_case(name)))
695 })
696 .unwrap_or_else(|| "Sub-module".to_string());
697 let purpose = extract_section(&existing, "Purpose")
698 .map(|s| s.trim().to_string())
699 .filter(|s| !s.is_empty());
700 let scope = parse_bullets(&extract_section(&existing, "Scope").unwrap_or_default());
701 let depends_on = parse_bullets(&extract_section(&existing, "Depends On").unwrap_or_default());
702 let mut changes = parse_changes(&extract_section(&existing, "Changes").unwrap_or_default());
703
704 if !changes.iter().any(|c| c.id == change_id) {
705 changes.push(ModuleChange {
706 id: change_id.to_string(),
707 completed: false,
708 planned: false,
709 });
710 }
711 changes.sort_by(|a, b| a.id.cmp(&b.id));
712
713 let md = generate_module_content(&title, purpose.as_deref(), &scope, &depends_on, &changes);
714 ito_common::io::write_std(&module_md, md)?;
715 Ok(())
716}
717
718fn next_module_id(modules_dir: &Path) -> Result<String, CreateError> {
719 let mut max_seen: u32 = 0;
720 let fs = StdFs;
721 if let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) {
722 for folder in entries {
723 if let Ok(parsed) = parse_module_id(&folder)
724 && let Ok(n) = parsed.module_id.as_str().parse::<u32>()
725 {
726 max_seen = max_seen.max(n);
727 }
728 }
729 }
730 Ok(format!("{n:03}", n = max_seen + 1))
731}
732
733fn validate_change_name(name: &str) -> Result<(), CreateError> {
734 if name.is_empty() {
736 return Err(CreateError::InvalidChangeName(
737 "Change name cannot be empty".to_string(),
738 ));
739 }
740 if name.chars().any(|c| c.is_ascii_uppercase()) {
741 return Err(CreateError::InvalidChangeName(
742 "Change name must be lowercase (use kebab-case)".to_string(),
743 ));
744 }
745 if name.chars().any(|c| c.is_whitespace()) {
746 return Err(CreateError::InvalidChangeName(
747 "Change name cannot contain spaces (use hyphens instead)".to_string(),
748 ));
749 }
750 if name.contains('_') {
751 return Err(CreateError::InvalidChangeName(
752 "Change name cannot contain underscores (use hyphens instead)".to_string(),
753 ));
754 }
755 if name.starts_with('-') {
756 return Err(CreateError::InvalidChangeName(
757 "Change name cannot start with a hyphen".to_string(),
758 ));
759 }
760 if name.ends_with('-') {
761 return Err(CreateError::InvalidChangeName(
762 "Change name cannot end with a hyphen".to_string(),
763 ));
764 }
765 if name.contains("--") {
766 return Err(CreateError::InvalidChangeName(
767 "Change name cannot contain consecutive hyphens".to_string(),
768 ));
769 }
770 if name
771 .chars()
772 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
773 {
774 return Err(CreateError::InvalidChangeName(
775 "Change name can only contain lowercase letters, numbers, and hyphens".to_string(),
776 ));
777 }
778 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
779 return Err(CreateError::InvalidChangeName(
780 "Change name must start with a letter".to_string(),
781 ));
782 }
783
784 let mut parts = name.split('-');
786 let Some(first) = parts.next() else {
787 return Err(CreateError::InvalidChangeName(
788 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
789 .to_string(),
790 ));
791 };
792 if first.is_empty() {
793 return Err(CreateError::InvalidChangeName(
794 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
795 .to_string(),
796 ));
797 }
798 let mut chars = first.chars();
799 if !chars.next().is_some_and(|c| c.is_ascii_lowercase()) {
800 return Err(CreateError::InvalidChangeName(
801 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
802 .to_string(),
803 ));
804 }
805 if chars.any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit())) {
806 return Err(CreateError::InvalidChangeName(
807 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
808 .to_string(),
809 ));
810 }
811 for part in parts {
812 if part.is_empty() {
813 return Err(CreateError::InvalidChangeName(
814 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
815 .to_string(),
816 ));
817 }
818 if part
819 .chars()
820 .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit()))
821 {
822 return Err(CreateError::InvalidChangeName(
823 "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
824 .to_string(),
825 ));
826 }
827 }
828
829 Ok(())
830}
831
832fn to_title_case(kebab: &str) -> String {
833 kebab
834 .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
835 .filter(|s| !s.is_empty())
836 .map(|w| {
837 let mut cs = w.chars();
838 match cs.next() {
839 None => String::new(),
840 Some(first) => {
841 let mut out = String::new();
842 out.push(first.to_ascii_uppercase());
843 out.push_str(&cs.as_str().to_ascii_lowercase());
844 out
845 }
846 }
847 })
848 .collect::<Vec<_>>()
849 .join(" ")
850}
851
852#[derive(Debug, Clone)]
853struct ModuleChange {
854 id: String,
855 completed: bool,
856 planned: bool,
857}
858
859fn add_change_to_module(
860 ito_path: &Path,
861 module_id: &str,
862 change_id: &str,
863) -> Result<(), CreateError> {
864 let modules_dir = paths::modules_dir(ito_path);
865 let module_folder = find_module_by_id(&modules_dir, module_id)
866 .ok_or_else(|| CreateError::ModuleNotFound(module_id.to_string()))?;
867 let module_md = modules_dir.join(&module_folder).join("module.md");
868 let existing = ito_common::io::read_to_string_std(&module_md)?;
869
870 let title = extract_title(&existing)
871 .or_else(|| module_folder.split('_').nth(1).map(to_title_case))
872 .unwrap_or_else(|| "Module".to_string());
873 let purpose = extract_section(&existing, "Purpose")
874 .map(|s| s.trim().to_string())
875 .filter(|s| !s.is_empty());
876 let scope = parse_bullets(&extract_section(&existing, "Scope").unwrap_or_default());
877 let depends_on = parse_bullets(&extract_section(&existing, "Depends On").unwrap_or_default());
878 let mut changes = parse_changes(&extract_section(&existing, "Changes").unwrap_or_default());
879
880 if !changes.iter().any(|c| c.id == change_id) {
881 changes.push(ModuleChange {
882 id: change_id.to_string(),
883 completed: false,
884 planned: false,
885 });
886 }
887 changes.sort_by(|a, b| a.id.cmp(&b.id));
888
889 let md = generate_module_content(&title, purpose.as_deref(), &scope, &depends_on, &changes);
890 ito_common::io::write_std(&module_md, md)?;
891 Ok(())
892}
893
894fn find_module_by_id(modules_dir: &Path, module_id: &str) -> Option<String> {
895 let fs = StdFs;
896 let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
897 return None;
898 };
899 for folder in entries {
900 if let Ok(parsed) = parse_module_id(&folder)
901 && parsed.module_id.as_str() == module_id
902 {
903 return Some(folder);
904 }
905 }
906 None
907}
908
909fn max_change_num_in_module_md(ito_path: &Path, module_id: &str) -> Result<u32, CreateError> {
910 let modules_dir = paths::modules_dir(ito_path);
911 let Some(folder) = find_module_by_id(&modules_dir, module_id) else {
912 return Ok(0);
913 };
914 let module_md = modules_dir.join(folder).join("module.md");
915 let content = ito_common::io::read_to_string_or_default(&module_md);
916 let mut max_seen: u32 = 0;
917 for token in content.split_whitespace() {
918 if let Ok(parsed) =
919 parse_change_id(token.trim_matches(|c: char| {
920 !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.'
921 }))
922 && change_belongs_to_namespace(&parsed, module_id)
923 && let Ok(n) = parsed.change_num.parse::<u32>()
924 {
925 max_seen = max_seen.max(n);
926 }
927 }
928 Ok(max_seen)
929}
930
931fn max_change_num_in_sub_module_md(
935 ito_path: &Path,
936 sub_module_id: &str,
937) -> Result<u32, CreateError> {
938 let modules_dir = paths::modules_dir(ito_path);
939 let Some(sub_module_dir) = find_sub_module_dir(&modules_dir, sub_module_id) else {
940 return Ok(0);
941 };
942 let module_md = sub_module_dir.join("module.md");
943 let content = ito_common::io::read_to_string_or_default(&module_md);
944 let mut max_seen: u32 = 0;
945 for token in content.split_whitespace() {
946 if let Ok(parsed) =
947 parse_change_id(token.trim_matches(|c: char| {
948 !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.'
949 }))
950 && change_belongs_to_namespace(&parsed, sub_module_id)
951 && let Ok(n) = parsed.change_num.parse::<u32>()
952 {
953 max_seen = max_seen.max(n);
954 }
955 }
956 Ok(max_seen)
957}
958
959fn extract_title(markdown: &str) -> Option<String> {
960 for line in markdown.lines() {
961 let line = line.trim();
962 if let Some(rest) = line.strip_prefix("# ") {
963 return Some(rest.trim().to_string());
964 }
965 }
966 None
967}
968
969fn extract_section(markdown: &str, header: &str) -> Option<String> {
970 let needle = format!("## {header}");
971 let mut in_section = false;
972 let mut out: Vec<&str> = Vec::new();
973 for line in markdown.lines() {
974 if line.trim() == needle {
975 in_section = true;
976 continue;
977 }
978 if in_section {
979 if line.trim_start().starts_with("## ") {
980 break;
981 }
982 out.push(line);
983 }
984 }
985 if !in_section {
986 return None;
987 }
988 Some(out.join("\n"))
989}
990
991fn parse_bullets(section: &str) -> Vec<String> {
992 let mut items = Vec::new();
993 for line in section.lines() {
994 let t = line.trim();
995 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
996 let s = rest.trim();
997 if !s.is_empty() {
998 items.push(s.to_string());
999 }
1000 }
1001 }
1002 items
1003}
1004
1005fn parse_changes(section: &str) -> Vec<ModuleChange> {
1006 let mut out = Vec::new();
1007 for line in section.lines() {
1008 let t = line.trim();
1009 if let Some(rest) = t.strip_prefix("- [") {
1010 if rest.len() < 3 {
1012 continue;
1013 }
1014 let checked = rest.chars().next().unwrap_or(' ');
1015 let completed = checked == 'x' || checked == 'X';
1016 let after = rest[3..].trim();
1017 let mut parts = after.split_whitespace();
1018 let Some(id) = parts.next() else {
1019 continue;
1020 };
1021 let planned = after.contains("(planned)");
1022 out.push(ModuleChange {
1023 id: id.to_string(),
1024 completed,
1025 planned,
1026 });
1027 continue;
1028 }
1029 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
1030 let rest = rest.trim();
1031 if rest.is_empty() {
1032 continue;
1033 }
1034 let id = rest.split_whitespace().next().unwrap_or("");
1035 if id.is_empty() {
1036 continue;
1037 }
1038 let planned = rest.contains("(planned)");
1039 out.push(ModuleChange {
1040 id: id.to_string(),
1041 completed: false,
1042 planned,
1043 });
1044 }
1045 }
1046 out
1047}
1048
1049fn generate_module_content<T: AsRef<str>>(
1050 title: &str,
1051 purpose: Option<&str>,
1052 scope: &[T],
1053 depends_on: &[T],
1054 changes: &[ModuleChange],
1055) -> String {
1056 let purpose = purpose
1057 .map(|s| s.to_string())
1058 .unwrap_or_else(|| "<!-- Describe the purpose of this module/epic -->".to_string());
1059 let scope_section = if scope.is_empty() {
1060 "<!-- List the scope of this module -->".to_string()
1061 } else {
1062 scope
1063 .iter()
1064 .map(|s| format!("- {}", s.as_ref()))
1065 .collect::<Vec<_>>()
1066 .join("\n")
1067 };
1068 let changes_section = if changes.is_empty() {
1069 "<!-- Changes will be listed here as they are created -->".to_string()
1070 } else {
1071 changes
1072 .iter()
1073 .map(|c| {
1074 let check = if c.completed { "x" } else { " " };
1075 let planned = if c.planned { " (planned)" } else { "" };
1076 format!("- [{check}] {}{planned}", c.id)
1077 })
1078 .collect::<Vec<_>>()
1079 .join("\n")
1080 };
1081
1082 let mut out = String::new();
1086 out.push_str(&format!("# {title}\n\n"));
1087
1088 out.push_str("## Purpose\n");
1089 out.push_str(&purpose);
1090 out.push_str("\n\n");
1091
1092 out.push_str("## Scope\n");
1093 out.push_str(&scope_section);
1094 out.push_str("\n\n");
1095
1096 if !depends_on.is_empty() {
1097 let depends_section = depends_on
1098 .iter()
1099 .map(|s| format!("- {}", s.as_ref()))
1100 .collect::<Vec<_>>()
1101 .join("\n");
1102 out.push_str("## Depends On\n");
1103 out.push_str(&depends_section);
1104 out.push_str("\n\n");
1105 }
1106
1107 out.push_str("## Changes\n");
1108 out.push_str(&changes_section);
1109 out.push('\n');
1110 out
1111}
1112
1113fn create_ungrouped_module(ito_path: &Path) -> Result<(), CreateError> {
1114 let modules_dir = paths::modules_dir(ito_path);
1115 ito_common::io::create_dir_all_std(&modules_dir)?;
1116 let dir = modules_dir.join("000_ungrouped");
1117 ito_common::io::create_dir_all_std(&dir)?;
1118 let empty: [&str; 0] = [];
1119 let md = generate_module_content(
1120 "Ungrouped",
1121 Some("Changes that do not belong to a specific module."),
1122 &["*"],
1123 &empty,
1124 &[],
1125 );
1126 ito_common::io::write_std(&dir.join("module.md"), md)?;
1127 Ok(())
1128}
1129
1130#[cfg(test)]
1131mod create_sub_module_tests;