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