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