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