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