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