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::HashMap;
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};
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    /// A change with the same id already exists.
39    #[error("Change '{0}' already exists")]
40    ChangeAlreadyExists(String),
41
42    /// Underlying I/O error.
43    #[error("I/O error: {0}")]
44    Io(#[from] io::Error),
45
46    /// JSON serialization/deserialization error.
47    #[error("JSON error: {0}")]
48    Json(#[from] serde_json::Error),
49}
50
51#[derive(Debug, Clone)]
52/// Result of creating (or resolving) a module.
53pub struct CreateModuleResult {
54    /// 3-digit module id.
55    pub module_id: String,
56    /// Module name (slug).
57    pub module_name: String,
58    /// Folder name under `{ito_path}/modules`.
59    pub folder_name: String,
60    /// `true` if the module was newly created.
61    pub created: bool,
62    /// Path to the module directory.
63    pub module_dir: PathBuf,
64    /// Path to `module.md`.
65    pub module_md: PathBuf,
66}
67
68#[derive(Debug, Clone)]
69/// Result of creating a change.
70pub struct CreateChangeResult {
71    /// Change id (folder name under `{ito_path}/changes`).
72    pub change_id: String,
73    /// Path to the change directory.
74    pub change_dir: PathBuf,
75}
76
77/// Create (or resolve) a module by name.
78///
79/// If a module with the same name already exists, this returns it with
80/// `created=false`.
81pub fn create_module(
82    ito_path: &Path,
83    name: &str,
84    scope: Vec<String>,
85    depends_on: Vec<String>,
86) -> Result<CreateModuleResult, CreateError> {
87    let name = name.trim();
88    if name.is_empty() {
89        return Err(CreateError::InvalidModuleName(name.to_string()));
90    }
91
92    let modules_dir = paths::modules_dir(ito_path);
93    ito_common::io::create_dir_all_std(&modules_dir)?;
94
95    // If a module with the same name already exists, return it.
96    if let Some(existing) = find_module_by_name(&modules_dir, name) {
97        // `find_module_by_name` only returns parseable module folder names.
98        let parsed = parse_module_id(&existing).expect("module folder should be parseable");
99        let module_id = parsed.module_id.to_string();
100        let module_name = parsed.module_name.unwrap_or_else(|| name.to_string());
101        let module_dir = modules_dir.join(&existing);
102        return Ok(CreateModuleResult {
103            module_id,
104            module_name,
105            folder_name: existing,
106            created: false,
107            module_dir: module_dir.clone(),
108            module_md: module_dir.join("module.md"),
109        });
110    }
111
112    let next_id = next_module_id(&modules_dir)?;
113    let folder = format!("{next_id}_{name}");
114    let module_dir = modules_dir.join(&folder);
115    ito_common::io::create_dir_all_std(&module_dir)?;
116
117    let title = to_title_case(name);
118    let md = generate_module_content(
119        &title,
120        Some("<!-- Describe the purpose of this module/epic -->"),
121        &scope,
122        &depends_on,
123        &[],
124    );
125    let module_md = module_dir.join("module.md");
126    ito_common::io::write_std(&module_md, md)?;
127
128    Ok(CreateModuleResult {
129        module_id: next_id,
130        module_name: name.to_string(),
131        folder_name: folder,
132        created: true,
133        module_dir,
134        module_md,
135    })
136}
137
138/// Create a new change directory and update the module's `module.md` checklist.
139pub fn create_change(
140    ito_path: &Path,
141    name: &str,
142    schema: &str,
143    module: Option<&str>,
144    description: Option<&str>,
145) -> Result<CreateChangeResult, CreateError> {
146    let name = name.trim();
147    validate_change_name(name)?;
148
149    let modules_dir = paths::modules_dir(ito_path);
150    let module_id = module
151        .and_then(|m| parse_module_id(m).ok().map(|p| p.module_id.to_string()))
152        .unwrap_or_else(|| "000".to_string());
153
154    // Ensure module exists (create ungrouped if missing).
155    if !modules_dir.exists() {
156        ito_common::io::create_dir_all_std(&modules_dir)?;
157    }
158    if !module_exists(&modules_dir, &module_id) {
159        if module_id == "000" {
160            create_ungrouped_module(ito_path)?;
161        } else {
162            return Err(CreateError::ModuleNotFound(module_id));
163        }
164    }
165
166    let next_num = allocate_next_change_number(ito_path, &module_id)?;
167    let folder = format!("{module_id}-{next_num:02}_{name}");
168
169    let changes_dir = paths::changes_dir(ito_path);
170    ito_common::io::create_dir_all_std(&changes_dir)?;
171    let change_dir = changes_dir.join(&folder);
172    if change_dir.exists() {
173        return Err(CreateError::ChangeAlreadyExists(folder));
174    }
175    ito_common::io::create_dir_all_std(&change_dir)?;
176
177    write_change_metadata(&change_dir, schema)?;
178
179    if let Some(desc) = description {
180        // Match TS: README header uses the change id, not the raw name.
181        let readme = format!("# {folder}\n\n{desc}\n");
182        ito_common::io::write_std(&change_dir.join("README.md"), readme)?;
183    }
184
185    add_change_to_module(ito_path, &module_id, &folder)?;
186
187    Ok(CreateChangeResult {
188        change_id: folder,
189        change_dir,
190    })
191}
192
193fn write_change_metadata(change_dir: &Path, schema: &str) -> Result<(), CreateError> {
194    let created = Utc::now().format("%Y-%m-%d").to_string();
195    let content = format!("schema: {schema}\ncreated: {created}\n");
196    ito_common::io::write_std(&change_dir.join(".ito.yaml"), content)?;
197    Ok(())
198}
199
200fn allocate_next_change_number(ito_path: &Path, module_id: &str) -> Result<u32, CreateError> {
201    // Lock file + JSON state mirrors TS implementation.
202    let state_dir = ito_path.join("workflows").join(".state");
203    ito_common::io::create_dir_all_std(&state_dir)?;
204    let lock_path = state_dir.join("change-allocations.lock");
205    let state_path = state_dir.join("change-allocations.json");
206
207    let lock = acquire_lock(&lock_path)?;
208    let mut state: AllocationState = if state_path.exists() {
209        serde_json::from_str(&ito_common::io::read_to_string_std(&state_path)?)?
210    } else {
211        AllocationState::default()
212    };
213
214    let mut max_seen: u32 = 0;
215    let changes_dir = paths::changes_dir(ito_path);
216    max_seen = max_seen.max(max_change_num_in_dir(&changes_dir, module_id));
217    max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
218        &paths::changes_archive_dir(ito_path),
219        module_id,
220    ));
221    max_seen = max_seen.max(max_change_num_in_archived_change_dirs(
222        &paths::archive_changes_dir(ito_path),
223        module_id,
224    ));
225
226    max_seen = max_seen.max(max_change_num_in_module_md(ito_path, module_id)?);
227    if let Some(ms) = state.modules.get(module_id) {
228        max_seen = max_seen.max(ms.last_change_num);
229    }
230
231    let next = max_seen + 1;
232    let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
233    state.modules.insert(
234        module_id.to_string(),
235        ModuleAllocationState {
236            last_change_num: next,
237            updated_at,
238        },
239    );
240
241    ito_common::io::write_std(&state_path, serde_json::to_string_pretty(&state)?)?;
242
243    drop(lock);
244    let _ = fs::remove_file(&lock_path);
245
246    Ok(next)
247}
248
249fn acquire_lock(path: &Path) -> Result<fs::File, CreateError> {
250    for _ in 0..10 {
251        match fs::OpenOptions::new()
252            .write(true)
253            .create_new(true)
254            .open(path)
255        {
256            Ok(f) => return Ok(f),
257            Err(_) => thread::sleep(Duration::from_millis(50)),
258        }
259    }
260    // final attempt with the original error
261    Ok(fs::OpenOptions::new()
262        .write(true)
263        .create_new(true)
264        .open(path)?)
265}
266
267#[derive(Debug, Serialize, Deserialize, Default)]
268struct AllocationState {
269    #[serde(default)]
270    modules: HashMap<String, ModuleAllocationState>,
271}
272
273#[derive(Debug, Serialize, Deserialize, Clone)]
274#[serde(rename_all = "camelCase")]
275struct ModuleAllocationState {
276    last_change_num: u32,
277    updated_at: String,
278}
279
280fn max_change_num_in_dir(dir: &Path, module_id: &str) -> u32 {
281    let mut max_seen = 0;
282    let fs = StdFs;
283    let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, dir) else {
284        return 0;
285    };
286    for name in entries {
287        if name == "archive" {
288            continue;
289        }
290        if let Ok(parsed) = parse_change_id(&name)
291            && parsed.module_id.as_str() == module_id
292            && let Ok(n) = parsed.change_num.parse::<u32>()
293        {
294            max_seen = max_seen.max(n);
295        }
296    }
297    max_seen
298}
299
300fn max_change_num_in_archived_change_dirs(archive_dir: &Path, module_id: &str) -> u32 {
301    let mut max_seen = 0;
302    let fs = StdFs;
303    let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, archive_dir) else {
304        return 0;
305    };
306    for name in entries {
307        // archived dirs are like 2026-01-26-006-05_port-list-show-validate
308        if name.len() <= 11 {
309            continue;
310        }
311        // Find substring after first 11 chars date + dash
312        let change_part = &name[11..];
313        if let Ok(parsed) = parse_change_id(change_part)
314            && parsed.module_id.as_str() == module_id
315            && let Ok(n) = parsed.change_num.parse::<u32>()
316        {
317            max_seen = max_seen.max(n);
318        }
319    }
320    max_seen
321}
322
323fn find_module_by_name(modules_dir: &Path, name: &str) -> Option<String> {
324    let fs = StdFs;
325    let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
326        return None;
327    };
328    for folder in entries {
329        if let Ok(parsed) = parse_module_id(&folder)
330            && parsed.module_name.as_deref() == Some(name)
331        {
332            return Some(folder);
333        }
334    }
335    None
336}
337
338fn module_exists(modules_dir: &Path, module_id: &str) -> bool {
339    let fs = StdFs;
340    let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
341        return false;
342    };
343    for folder in entries {
344        if let Ok(parsed) = parse_module_id(&folder)
345            && parsed.module_id.as_str() == module_id
346        {
347            return true;
348        }
349    }
350    false
351}
352
353fn next_module_id(modules_dir: &Path) -> Result<String, CreateError> {
354    let mut max_seen: u32 = 0;
355    let fs = StdFs;
356    if let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) {
357        for folder in entries {
358            if let Ok(parsed) = parse_module_id(&folder)
359                && let Ok(n) = parsed.module_id.as_str().parse::<u32>()
360            {
361                max_seen = max_seen.max(n);
362            }
363        }
364    }
365    Ok(format!("{n:03}", n = max_seen + 1))
366}
367
368fn validate_change_name(name: &str) -> Result<(), CreateError> {
369    // Mirrors `src/utils/change-utils.ts` validateChangeName.
370    if name.is_empty() {
371        return Err(CreateError::InvalidChangeName(
372            "Change name cannot be empty".to_string(),
373        ));
374    }
375    if name.chars().any(|c| c.is_ascii_uppercase()) {
376        return Err(CreateError::InvalidChangeName(
377            "Change name must be lowercase (use kebab-case)".to_string(),
378        ));
379    }
380    if name.chars().any(|c| c.is_whitespace()) {
381        return Err(CreateError::InvalidChangeName(
382            "Change name cannot contain spaces (use hyphens instead)".to_string(),
383        ));
384    }
385    if name.contains('_') {
386        return Err(CreateError::InvalidChangeName(
387            "Change name cannot contain underscores (use hyphens instead)".to_string(),
388        ));
389    }
390    if name.starts_with('-') {
391        return Err(CreateError::InvalidChangeName(
392            "Change name cannot start with a hyphen".to_string(),
393        ));
394    }
395    if name.ends_with('-') {
396        return Err(CreateError::InvalidChangeName(
397            "Change name cannot end with a hyphen".to_string(),
398        ));
399    }
400    if name.contains("--") {
401        return Err(CreateError::InvalidChangeName(
402            "Change name cannot contain consecutive hyphens".to_string(),
403        ));
404    }
405    if name
406        .chars()
407        .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
408    {
409        return Err(CreateError::InvalidChangeName(
410            "Change name can only contain lowercase letters, numbers, and hyphens".to_string(),
411        ));
412    }
413    if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
414        return Err(CreateError::InvalidChangeName(
415            "Change name must start with a letter".to_string(),
416        ));
417    }
418
419    // Structural check: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
420    let mut parts = name.split('-');
421    let Some(first) = parts.next() else {
422        return Err(CreateError::InvalidChangeName(
423            "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
424                .to_string(),
425        ));
426    };
427    if first.is_empty() {
428        return Err(CreateError::InvalidChangeName(
429            "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
430                .to_string(),
431        ));
432    }
433    let mut chars = first.chars();
434    if !chars.next().is_some_and(|c| c.is_ascii_lowercase()) {
435        return Err(CreateError::InvalidChangeName(
436            "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
437                .to_string(),
438        ));
439    }
440    if chars.any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit())) {
441        return Err(CreateError::InvalidChangeName(
442            "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
443                .to_string(),
444        ));
445    }
446    for part in parts {
447        if part.is_empty() {
448            return Err(CreateError::InvalidChangeName(
449                "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
450                    .to_string(),
451            ));
452        }
453        if part
454            .chars()
455            .any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit()))
456        {
457            return Err(CreateError::InvalidChangeName(
458                "Change name must follow kebab-case convention (e.g., add-auth, refactor-db)"
459                    .to_string(),
460            ));
461        }
462    }
463
464    Ok(())
465}
466
467fn to_title_case(kebab: &str) -> String {
468    kebab
469        .split(|c: char| c == '-' || c == '_' || c.is_whitespace())
470        .filter(|s| !s.is_empty())
471        .map(|w| {
472            let mut cs = w.chars();
473            match cs.next() {
474                None => String::new(),
475                Some(first) => {
476                    let mut out = String::new();
477                    out.push(first.to_ascii_uppercase());
478                    out.push_str(&cs.as_str().to_ascii_lowercase());
479                    out
480                }
481            }
482        })
483        .collect::<Vec<_>>()
484        .join(" ")
485}
486
487#[derive(Debug, Clone)]
488struct ModuleChange {
489    id: String,
490    completed: bool,
491    planned: bool,
492}
493
494fn add_change_to_module(
495    ito_path: &Path,
496    module_id: &str,
497    change_id: &str,
498) -> Result<(), CreateError> {
499    let modules_dir = paths::modules_dir(ito_path);
500    let module_folder = find_module_by_id(&modules_dir, module_id)
501        .ok_or_else(|| CreateError::ModuleNotFound(module_id.to_string()))?;
502    let module_md = modules_dir.join(&module_folder).join("module.md");
503    let existing = ito_common::io::read_to_string_std(&module_md)?;
504
505    let title = extract_title(&existing)
506        .or_else(|| module_folder.split('_').nth(1).map(to_title_case))
507        .unwrap_or_else(|| "Module".to_string());
508    let purpose = extract_section(&existing, "Purpose")
509        .map(|s| s.trim().to_string())
510        .filter(|s| !s.is_empty());
511    let scope = parse_bullets(&extract_section(&existing, "Scope").unwrap_or_default());
512    let depends_on = parse_bullets(&extract_section(&existing, "Depends On").unwrap_or_default());
513    let mut changes = parse_changes(&extract_section(&existing, "Changes").unwrap_or_default());
514
515    if !changes.iter().any(|c| c.id == change_id) {
516        changes.push(ModuleChange {
517            id: change_id.to_string(),
518            completed: false,
519            planned: false,
520        });
521    }
522
523    let md = generate_module_content(&title, purpose.as_deref(), &scope, &depends_on, &changes);
524    ito_common::io::write_std(&module_md, md)?;
525    Ok(())
526}
527
528fn find_module_by_id(modules_dir: &Path, module_id: &str) -> Option<String> {
529    let fs = StdFs;
530    let Ok(entries) = ito_domain::discovery::list_dir_names(&fs, modules_dir) else {
531        return None;
532    };
533    for folder in entries {
534        if let Ok(parsed) = parse_module_id(&folder)
535            && parsed.module_id.as_str() == module_id
536        {
537            return Some(folder);
538        }
539    }
540    None
541}
542
543fn max_change_num_in_module_md(ito_path: &Path, module_id: &str) -> Result<u32, CreateError> {
544    let modules_dir = paths::modules_dir(ito_path);
545    let Some(folder) = find_module_by_id(&modules_dir, module_id) else {
546        return Ok(0);
547    };
548    let module_md = modules_dir.join(folder).join("module.md");
549    let content = ito_common::io::read_to_string_or_default(&module_md);
550    let mut max_seen: u32 = 0;
551    for token in content.split_whitespace() {
552        if let Ok(parsed) = parse_change_id(
553            token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_'),
554        ) && parsed.module_id.as_str() == module_id
555            && let Ok(n) = parsed.change_num.parse::<u32>()
556        {
557            max_seen = max_seen.max(n);
558        }
559    }
560    Ok(max_seen)
561}
562
563fn extract_title(markdown: &str) -> Option<String> {
564    for line in markdown.lines() {
565        let line = line.trim();
566        if let Some(rest) = line.strip_prefix("# ") {
567            return Some(rest.trim().to_string());
568        }
569    }
570    None
571}
572
573fn extract_section(markdown: &str, header: &str) -> Option<String> {
574    let needle = format!("## {header}");
575    let mut in_section = false;
576    let mut out: Vec<&str> = Vec::new();
577    for line in markdown.lines() {
578        if line.trim() == needle {
579            in_section = true;
580            continue;
581        }
582        if in_section {
583            if line.trim_start().starts_with("## ") {
584                break;
585            }
586            out.push(line);
587        }
588    }
589    if !in_section {
590        return None;
591    }
592    Some(out.join("\n"))
593}
594
595fn parse_bullets(section: &str) -> Vec<String> {
596    let mut items = Vec::new();
597    for line in section.lines() {
598        let t = line.trim();
599        if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
600            let s = rest.trim();
601            if !s.is_empty() {
602                items.push(s.to_string());
603            }
604        }
605    }
606    items
607}
608
609fn parse_changes(section: &str) -> Vec<ModuleChange> {
610    let mut out = Vec::new();
611    for line in section.lines() {
612        let t = line.trim();
613        if let Some(rest) = t.strip_prefix("- [") {
614            // - [x] id (planned)
615            if rest.len() < 3 {
616                continue;
617            }
618            let checked = rest.chars().next().unwrap_or(' ');
619            let completed = checked == 'x' || checked == 'X';
620            let after = rest[3..].trim();
621            let mut parts = after.split_whitespace();
622            let Some(id) = parts.next() else {
623                continue;
624            };
625            let planned = after.contains("(planned)");
626            out.push(ModuleChange {
627                id: id.to_string(),
628                completed,
629                planned,
630            });
631            continue;
632        }
633        if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
634            let rest = rest.trim();
635            if rest.is_empty() {
636                continue;
637            }
638            let id = rest.split_whitespace().next().unwrap_or("");
639            if id.is_empty() {
640                continue;
641            }
642            let planned = rest.contains("(planned)");
643            out.push(ModuleChange {
644                id: id.to_string(),
645                completed: false,
646                planned,
647            });
648        }
649    }
650    out
651}
652
653fn generate_module_content<T: AsRef<str>>(
654    title: &str,
655    purpose: Option<&str>,
656    scope: &[T],
657    depends_on: &[T],
658    changes: &[ModuleChange],
659) -> String {
660    let purpose = purpose
661        .map(|s| s.to_string())
662        .unwrap_or_else(|| "<!-- Describe the purpose of this module/epic -->".to_string());
663    let scope_section = if scope.is_empty() {
664        "<!-- List the scope of this module -->".to_string()
665    } else {
666        scope
667            .iter()
668            .map(|s| format!("- {}", s.as_ref()))
669            .collect::<Vec<_>>()
670            .join("\n")
671    };
672    let changes_section = if changes.is_empty() {
673        "<!-- Changes will be listed here as they are created -->".to_string()
674    } else {
675        changes
676            .iter()
677            .map(|c| {
678                let check = if c.completed { "x" } else { " " };
679                let planned = if c.planned { " (planned)" } else { "" };
680                format!("- [{check}] {}{planned}", c.id)
681            })
682            .collect::<Vec<_>>()
683            .join("\n")
684    };
685
686    // Match TS formatting (generateModuleContent):
687    // - No blank line between section header and content
688    // - Omit "Depends On" section when empty
689    let mut out = String::new();
690    out.push_str(&format!("# {title}\n\n"));
691
692    out.push_str("## Purpose\n");
693    out.push_str(&purpose);
694    out.push_str("\n\n");
695
696    out.push_str("## Scope\n");
697    out.push_str(&scope_section);
698    out.push_str("\n\n");
699
700    if !depends_on.is_empty() {
701        let depends_section = depends_on
702            .iter()
703            .map(|s| format!("- {}", s.as_ref()))
704            .collect::<Vec<_>>()
705            .join("\n");
706        out.push_str("## Depends On\n");
707        out.push_str(&depends_section);
708        out.push_str("\n\n");
709    }
710
711    out.push_str("## Changes\n");
712    out.push_str(&changes_section);
713    out.push('\n');
714    out
715}
716
717fn create_ungrouped_module(ito_path: &Path) -> Result<(), CreateError> {
718    let modules_dir = paths::modules_dir(ito_path);
719    ito_common::io::create_dir_all_std(&modules_dir)?;
720    let dir = modules_dir.join("000_ungrouped");
721    ito_common::io::create_dir_all_std(&dir)?;
722    let empty: [&str; 0] = [];
723    let md = generate_module_content(
724        "Ungrouped",
725        Some("Changes that do not belong to a specific module."),
726        &["*"],
727        &empty,
728        &[],
729    );
730    ito_common::io::write_std(&dir.join("module.md"), md)?;
731    Ok(())
732}