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