Skip to main content

ito_core/create/
mod.rs

1//! Creation helpers for modules and changes.
2//!
3//! This module contains the filesystem operations behind `ito create module`
4//! and `ito create change`.
5//!
6//! Functions here are designed to be called by the CLI layer and return
7//! structured results suitable for JSON output.
8
9use 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)]
23/// Errors that can occur while creating modules or changes.
24pub enum CreateError {
25    /// The provided module name is invalid.
26    #[error("Invalid module name '{0}'")]
27    InvalidModuleName(String),
28
29    // Match TS: the message is already user-facing (e.g. "Change name must be lowercase ...").
30    /// The provided change name is invalid.
31    #[error("{0}")]
32    InvalidChangeName(String),
33
34    /// The requested module id does not exist.
35    #[error("Module '{0}' not found")]
36    ModuleNotFound(String),
37
38    /// The requested sub-module id does not exist.
39    #[error("Sub-module '{0}' not found")]
40    SubModuleNotFound(String),
41
42    /// Mutually exclusive flags were both provided.
43    #[error("{0}")]
44    MutuallyExclusive(String),
45
46    /// A change with the same id already exists.
47    #[error("Change '{0}' already exists")]
48    ChangeAlreadyExists(String),
49
50    /// A sub-module with the same name already exists under the parent module.
51    #[error("Sub-module '{0}' already exists under module '{1}'")]
52    DuplicateSubModuleName(String, String),
53
54    /// All sub-module number slots (01–99) under the parent module are exhausted.
55    #[error("Sub-module number exhausted under module '{0}'; maximum of 99 sub-modules allowed")]
56    SubModuleNumberExhausted(String),
57
58    /// Underlying I/O error.
59    #[error("I/O error: {0}")]
60    Io(#[from] io::Error),
61
62    /// JSON serialization/deserialization error.
63    #[error("JSON error: {0}")]
64    Json(#[from] serde_json::Error),
65}
66
67#[derive(Debug, Clone)]
68/// Result of creating (or resolving) a module.
69pub struct CreateModuleResult {
70    /// 3-digit module id.
71    pub module_id: String,
72    /// Module name (slug).
73    pub module_name: String,
74    /// Folder name under `{ito_path}/modules`.
75    pub folder_name: String,
76    /// `true` if the module was newly created.
77    pub created: bool,
78    /// Path to the module directory.
79    pub module_dir: PathBuf,
80    /// Path to `module.md`.
81    pub module_md: PathBuf,
82}
83
84#[derive(Debug, Clone)]
85/// Result of creating a change.
86pub struct CreateChangeResult {
87    /// Change id (folder name under `{ito_path}/changes`).
88    pub change_id: String,
89    /// Path to the change directory.
90    pub change_dir: PathBuf,
91}
92
93#[derive(Debug, Clone)]
94/// Result of creating a sub-module.
95pub struct CreateSubModuleResult {
96    /// Canonical sub-module id (e.g., `"024.01"`).
97    pub sub_module_id: String,
98    /// Sub-module name (slug).
99    pub sub_module_name: String,
100    /// Parent module id (e.g., `"024"`).
101    pub parent_module_id: String,
102    /// Path to the sub-module directory.
103    pub sub_module_dir: PathBuf,
104}
105
106/// Create (or resolve) a module by name.
107///
108/// If a module with the same name already exists, this returns it with
109/// `created=false`.
110pub 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 a module with the same name already exists, return it.
126    if let Some(existing) = find_module_by_name(&modules_dir, name) {
127        // `find_module_by_name` only returns parseable module folder names.
128        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
168/// Create a new change directory and update the module's `module.md` checklist.
169///
170/// When `module` is `Some`, the change is scoped to that module:
171/// - The allocation namespace is the module's `NNN` identifier.
172/// - The folder name uses the `NNN-NN_name` canonical form.
173/// - The checklist entry is written to the module's `module.md`.
174///
175/// When `module` is `None`, the change is placed in the default `000` namespace.
176pub 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
186/// Create a new change scoped to a sub-module.
187///
188/// `sub_module` must be a valid `NNN.SS` (or `NNN.SS_name`) identifier.
189/// The parent module must already exist; the sub-module directory must exist
190/// under `modules/NNN_<name>/sub/SS_<name>/`.
191pub 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
201/// Create a new sub-module directory under an existing parent module.
202///
203/// Allocates the next available sub-module number, creates the `sub/SS_name/`
204/// directory, and writes a `module.md` with the given title and optional
205/// description.
206///
207/// Returns [`CreateError::ModuleNotFound`] when the parent module does not
208/// exist, and [`CreateError::DuplicateSubModuleName`] when a sub-module with
209/// the same name already exists under that parent.
210pub 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    // Reuse change-name validation rules: lowercase kebab-case, no underscores.
218    validate_change_name(name)?;
219
220    let modules_dir = paths::modules_dir(ito_path);
221
222    // Resolve the parent module id.
223    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    // Parent module must exist.
229    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    // Check for duplicate name.
237    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    // Allocate the next sub-module number.
252    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    // Canonical composite id: "NNN.SS"
261    let sub_module_id = format!("{parent_id}.{next_sub_num:02}");
262
263    // Write module.md.
264    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
282/// Scan a `sub/` directory and return the next available sub-module number.
283fn 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    // Ensure modules dir exists.
312    if !modules_dir.exists() {
313        ito_common::io::create_dir_all_std(&modules_dir)?;
314    }
315
316    // Resolve the allocation namespace key and folder prefix.
317    // When sub_module is provided, the namespace is "NNN.SS" and the folder
318    // prefix is "NNN.SS". Otherwise the namespace is the parent module id.
319    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        // Parent module must exist.
327        if !module_exists(&modules_dir, &parent_id) {
328            return Err(CreateError::ModuleNotFound(parent_id));
329        }
330
331        // Sub-module directory must exist.
332        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        // Match TS: README header uses the change id, not the raw name.
376        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
395/// Identifies where the checklist entry for a new change should be written.
396enum ChecklistTarget {
397    /// Write to the parent module's `module.md`.
398    Module(String),
399    /// Write to the sub-module's `module.md` (composite id like `"024.01"`).
400    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    // Lock file + JSON state mirrors TS implementation.
412    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    // For sub-module namespaces (NNN.SS), also scan the sub-module's module.md.
437    // For plain module namespaces (NNN), scan the parent module's module.md.
438    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    // final attempt with the original error
477    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
496/// Returns `true` when a parsed change id belongs to the given namespace key.
497///
498/// - For a plain module namespace (`"024"`): matches legacy `NNN-NN_name` changes
499///   where `module_id == "024"` and there is no sub-module component.
500/// - For a sub-module namespace (`"024.01"`): matches sub-module changes where
501///   `sub_module_id == "024.01"`.
502fn change_belongs_to_namespace(
503    parsed: &ito_common::id::ParsedChangeId,
504    namespace_key: &str,
505) -> bool {
506    if namespace_key.contains('.') {
507        // Sub-module namespace: match on sub_module_id.
508        parsed
509            .sub_module_id
510            .as_ref()
511            .map(|s| s.as_str() == namespace_key)
512            .unwrap_or(false)
513    } else {
514        // Plain module namespace: match on module_id with no sub-module component.
515        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        // archived dirs are like 2026-01-26-006-05_port-list-show-validate
547        if name.len() <= 11 {
548            continue;
549        }
550        // Find substring after first 11 chars date + dash
551        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
592/// Check whether a sub-module directory exists under `modules/NNN_<name>/sub/SS_<name>/`.
593fn 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
609/// Find the sub-module directory path for a given composite id (e.g., `"024.01"`).
610///
611/// Returns the path to the sub-module directory (e.g.,
612/// `.ito/modules/024_backend/sub/01_auth`) if it exists.
613fn 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
628/// Add a change entry to a sub-module's `module.md` checklist.
629///
630/// Mirrors [`add_change_to_module`] but targets the sub-module's own
631/// `module.md` at `.ito/modules/NNN_<parent>/sub/SS_<name>/module.md`.
632fn 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    // If the sub-module doesn't have a module.md yet, create a minimal one.
644    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    // Mirrors `src/utils/change-utils.ts` validateChangeName.
703    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    // Structural check: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
753    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
899/// Scan a sub-module's `module.md` for the maximum change number seen.
900///
901/// `sub_module_id` is the composite `NNN.SS` key (e.g., `"024.01"`).
902fn 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            // - [x] id (planned)
979            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    // Match TS formatting (generateModuleContent):
1051    // - No blank line between section header and content
1052    // - Omit "Depends On" section when empty
1053    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;