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