1use 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)]
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) -> 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 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
145pub 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 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 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 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 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 if name.len() <= 11 {
316 continue;
317 }
318 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 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 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 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 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}