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