1use 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)]
23pub enum CreateError {
25 #[error("Invalid module name '{0}'")]
27 InvalidModuleName(String),
28
29 #[error("{0}")]
32 InvalidChangeName(String),
33
34 #[error("Module '{0}' not found")]
36 ModuleNotFound(String),
37
38 #[error("Change '{0}' already exists")]
40 ChangeAlreadyExists(String),
41
42 #[error("I/O error: {0}")]
44 Io(#[from] io::Error),
45
46 #[error("JSON error: {0}")]
48 Json(#[from] serde_json::Error),
49}
50
51#[derive(Debug, Clone)]
52pub struct CreateModuleResult {
54 pub module_id: String,
56 pub module_name: String,
58 pub folder_name: String,
60 pub created: bool,
62 pub module_dir: PathBuf,
64 pub module_md: PathBuf,
66}
67
68#[derive(Debug, Clone)]
69pub struct CreateChangeResult {
71 pub change_id: String,
73 pub change_dir: PathBuf,
75}
76
77pub 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 let Some(existing) = find_module_by_name(&modules_dir, name) {
98 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
139pub 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 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 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 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 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 if name.len() <= 11 {
310 continue;
311 }
312 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 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 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 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 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}