Skip to content

Commit 4348485

Browse files
montfortclaude
andcommitted
feat: implement devtrail new command for document creation
Replaces devtrail-new.sh (267 lines) with native CLI command. Supports interactive mode (dialoguer) and non-interactive via --doc-type/-t and --title flags. Adds DocType helpers: directory(), display_name(), from_str_loose(), ALL constant. Includes 4 integration tests and 6 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d9ea874 commit 4348485

3 files changed

Lines changed: 360 additions & 0 deletions

File tree

cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod compliance;
77
pub mod explore;
88
pub mod init;
99
pub mod metrics;
10+
pub mod new;
1011
pub mod remove;
1112
pub mod repair;
1213
pub mod status;

cli/src/commands/new.rs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
use anyhow::{bail, Context, Result};
2+
use chrono::Local;
3+
use colored::Colorize;
4+
use dialoguer::{theme::ColorfulTheme, Input, Select};
5+
use std::path::PathBuf;
6+
7+
use crate::config::DevTrailConfig;
8+
use crate::document::DocType;
9+
use crate::utils;
10+
11+
pub fn run(path: &str, doc_type_arg: Option<&str>, title_arg: Option<&str>) -> Result<()> {
12+
let resolved = utils::resolve_project_root(path)
13+
.ok_or_else(|| anyhow::anyhow!("DevTrail not installed. Run 'devtrail init' first."))?;
14+
let target = resolved.path;
15+
let devtrail_dir = target.join(".devtrail");
16+
17+
let config = DevTrailConfig::load(&target).unwrap_or_default();
18+
let lang = &config.language;
19+
20+
// Select document type
21+
let doc_type = match doc_type_arg {
22+
Some(t) => DocType::from_str_loose(t).ok_or_else(|| {
23+
anyhow::anyhow!(
24+
"Unknown document type '{}'. Valid types: {}",
25+
t,
26+
DocType::ALL
27+
.iter()
28+
.map(|d| d.prefix().to_lowercase())
29+
.collect::<Vec<_>>()
30+
.join(", ")
31+
)
32+
})?,
33+
None => select_type_interactive()?,
34+
};
35+
36+
// Get title
37+
let title = match title_arg {
38+
Some(t) => t.to_string(),
39+
None => Input::with_theme(&ColorfulTheme::default())
40+
.with_prompt("Title")
41+
.interact_text()?,
42+
};
43+
if title.trim().is_empty() {
44+
bail!("Title is required");
45+
}
46+
47+
// Generate slug, date, sequence
48+
let slug = slugify(&title);
49+
let today = Local::now().format("%Y-%m-%d").to_string();
50+
let doc_dir = devtrail_dir.join(doc_type.directory());
51+
let seq = next_sequence_number(&doc_dir, doc_type, &today);
52+
53+
// Load and fill template
54+
let template_path = resolve_template_path(&devtrail_dir, doc_type, lang);
55+
let template = std::fs::read_to_string(&template_path)
56+
.with_context(|| format!("Template not found: {}", template_path.display()))?;
57+
58+
let id = format!("{}-{}-{}", doc_type.prefix(), today, seq);
59+
let content = template
60+
.replace("YYYY-MM-DD-NNN", &format!("{}-{}", today, seq))
61+
.replace("YYYY-MM-DD", &today)
62+
.replace("[Descriptive title of the action]", &title)
63+
.replace("[Título descriptivo de la acción]", &title)
64+
.replace("[Decision title]", &title)
65+
.replace("[Título de la decisión]", &title)
66+
.replace("[Architectural decision title]", &title)
67+
.replace("[Título de la decisión arquitectónica]", &title)
68+
.replace("[Assessment title]", &title)
69+
.replace("[Título de la evaluación]", &title)
70+
.replace("[Model/System name]", &title)
71+
.replace("[Nombre del modelo/sistema]", &title)
72+
.replace("[System name]", &title)
73+
.replace("[Nombre del sistema]", &title)
74+
.replace("[Assessment scope]", &title)
75+
.replace("[Alcance de la evaluación]", &title)
76+
.replace("[Title]", &title)
77+
.replace("[Título]", &title)
78+
.replace("[agent-name-v1.0]", "manual-user")
79+
.replace("[nombre-agente-v1.0]", "manual-user")
80+
.replace("id: AILOG-YYYY-MM-DD-NNN", &format!("id: {}", id))
81+
.replace("id: AIDEC-YYYY-MM-DD-NNN", &format!("id: {}", id))
82+
.replace("id: ADR-YYYY-MM-DD-NNN", &format!("id: {}", id))
83+
.replace("id: ETH-YYYY-MM-DD-NNN", &format!("id: {}", id))
84+
.replace("id: REQ-YYYY-MM-DD-NNN", &format!("id: {}", id))
85+
.replace("id: TES-YYYY-MM-DD-NNN", &format!("id: {}", id))
86+
.replace("id: INC-YYYY-MM-DD-NNN", &format!("id: {}", id))
87+
.replace("id: TDE-YYYY-MM-DD-NNN", &format!("id: {}", id))
88+
.replace("id: SEC-YYYY-MM-DD-NNN", &format!("id: {}", id))
89+
.replace("id: MCARD-YYYY-MM-DD-NNN", &format!("id: {}", id))
90+
.replace("id: SBOM-YYYY-MM-DD-NNN", &format!("id: {}", id))
91+
.replace("id: DPIA-YYYY-MM-DD-NNN", &format!("id: {}", id));
92+
93+
// Write file
94+
let filename = format!("{}-{}-{}-{}.md", doc_type.prefix(), today, seq, slug);
95+
utils::ensure_dir(&doc_dir)?;
96+
let filepath = doc_dir.join(&filename);
97+
std::fs::write(&filepath, content)?;
98+
99+
// Print result
100+
let rel_path = filepath
101+
.strip_prefix(&target)
102+
.unwrap_or(&filepath)
103+
.display();
104+
println!();
105+
utils::success(&format!("Created: {}", rel_path));
106+
println!();
107+
println!(" {}", "Next steps:".bold());
108+
println!(" 1. Edit the document to fill in details");
109+
println!(
110+
" 2. Commit: {}",
111+
format!("git add {}", rel_path).dimmed()
112+
);
113+
println!();
114+
115+
Ok(())
116+
}
117+
118+
fn select_type_interactive() -> Result<DocType> {
119+
let items: Vec<String> = DocType::ALL
120+
.iter()
121+
.map(|t| format!("{:<6} — {}", t.prefix().to_lowercase(), t.display_name()))
122+
.collect();
123+
124+
let selection = Select::with_theme(&ColorfulTheme::default())
125+
.with_prompt("Document type")
126+
.items(&items)
127+
.default(0)
128+
.interact()?;
129+
130+
Ok(DocType::ALL[selection])
131+
}
132+
133+
fn slugify(title: &str) -> String {
134+
let lower = title.to_lowercase();
135+
let parts: Vec<&str> = lower
136+
.split(|c: char| !c.is_ascii_alphanumeric())
137+
.filter(|s| !s.is_empty())
138+
.collect();
139+
let slug = parts.join("-");
140+
if slug.len() > 50 {
141+
slug[..50].trim_end_matches('-').to_string()
142+
} else {
143+
slug
144+
}
145+
}
146+
147+
fn next_sequence_number(doc_dir: &std::path::Path, doc_type: DocType, today: &str) -> String {
148+
let prefix_pattern = format!("{}-{}-", doc_type.prefix(), today);
149+
let mut max_seq = 0u32;
150+
151+
if doc_dir.exists() {
152+
if let Ok(entries) = std::fs::read_dir(doc_dir) {
153+
for entry in entries.flatten() {
154+
let name = entry.file_name();
155+
let name = name.to_str().unwrap_or("");
156+
if let Some(rest) = name.strip_prefix(&prefix_pattern) {
157+
if rest.len() >= 3 {
158+
if let Ok(n) = rest[..3].parse::<u32>() {
159+
max_seq = max_seq.max(n);
160+
}
161+
}
162+
}
163+
}
164+
}
165+
}
166+
167+
format!("{:03}", max_seq + 1)
168+
}
169+
170+
fn resolve_template_path(
171+
devtrail_dir: &std::path::Path,
172+
doc_type: DocType,
173+
lang: &str,
174+
) -> PathBuf {
175+
let template_name = format!("TEMPLATE-{}.md", doc_type.prefix());
176+
if lang == "es" {
177+
let es_path = devtrail_dir
178+
.join("templates/i18n/es")
179+
.join(&template_name);
180+
if es_path.exists() {
181+
return es_path;
182+
}
183+
}
184+
devtrail_dir.join("templates").join(&template_name)
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
#[test]
192+
fn test_slugify() {
193+
assert_eq!(slugify("Hello World"), "hello-world");
194+
assert_eq!(slugify("Fix: auth bug #123"), "fix-auth-bug-123");
195+
assert_eq!(slugify(" spaces everywhere "), "spaces-everywhere");
196+
assert_eq!(slugify("UPPER-case_mixed"), "upper-case-mixed");
197+
}
198+
199+
#[test]
200+
fn test_slugify_truncates() {
201+
let long_title = "a".repeat(60);
202+
assert!(slugify(&long_title).len() <= 50);
203+
}
204+
205+
#[test]
206+
fn test_next_sequence_empty_dir() {
207+
let dir = tempfile::TempDir::new().unwrap();
208+
assert_eq!(
209+
next_sequence_number(dir.path(), DocType::Ailog, "2026-04-01"),
210+
"001"
211+
);
212+
}
213+
214+
#[test]
215+
fn test_next_sequence_increments() {
216+
let dir = tempfile::TempDir::new().unwrap();
217+
std::fs::write(
218+
dir.path().join("AILOG-2026-04-01-001-first.md"),
219+
"test",
220+
)
221+
.unwrap();
222+
assert_eq!(
223+
next_sequence_number(dir.path(), DocType::Ailog, "2026-04-01"),
224+
"002"
225+
);
226+
}
227+
}

cli/tests/new_test.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use assert_cmd::Command;
2+
use predicates::prelude::*;
3+
use tempfile::TempDir;
4+
5+
/// Set up a minimal DevTrail installation with a template for the given type
6+
fn setup_devtrail_with_template(dir: &std::path::Path, doc_type: &str) {
7+
let devtrail = dir.join(".devtrail");
8+
std::fs::create_dir_all(devtrail.join("templates")).unwrap();
9+
std::fs::write(
10+
devtrail.join("config.yml"),
11+
"language: en\n",
12+
)
13+
.unwrap();
14+
15+
let template_name = format!("TEMPLATE-{}.md", doc_type.to_uppercase());
16+
let template_content = format!(
17+
"---\nid: {}-YYYY-MM-DD-NNN\ntitle: \"[Title]\"\nstatus: draft\ncreated: YYYY-MM-DD\nagent: \"[agent-name-v1.0]\"\nconfidence: medium\nreview_required: false\nrisk_level: low\n---\n\n# [Title]\n",
18+
doc_type.to_uppercase()
19+
);
20+
std::fs::write(
21+
devtrail.join("templates").join(&template_name),
22+
template_content,
23+
)
24+
.unwrap();
25+
}
26+
27+
#[test]
28+
fn test_new_requires_devtrail_installed() {
29+
let dir = TempDir::new().unwrap();
30+
31+
let mut cmd = Command::cargo_bin("devtrail").unwrap();
32+
cmd.arg("new")
33+
.arg("--doc-type")
34+
.arg("ailog")
35+
.arg("--title")
36+
.arg("Test")
37+
.arg(dir.path().to_str().unwrap())
38+
.assert()
39+
.failure()
40+
.stderr(predicate::str::contains("not installed"));
41+
}
42+
43+
#[test]
44+
fn test_new_with_type_and_title_args() {
45+
let dir = TempDir::new().unwrap();
46+
setup_devtrail_with_template(dir.path(), "AILOG");
47+
48+
let mut cmd = Command::cargo_bin("devtrail").unwrap();
49+
cmd.arg("new")
50+
.arg("--doc-type")
51+
.arg("ailog")
52+
.arg("--title")
53+
.arg("Test Document")
54+
.arg(dir.path().to_str().unwrap())
55+
.assert()
56+
.success()
57+
.stdout(predicate::str::contains("Created:"));
58+
59+
// Verify file was created in the correct directory
60+
let agent_logs_dir = dir.path().join(".devtrail/07-ai-audit/agent-logs");
61+
assert!(agent_logs_dir.exists(), "agent-logs directory should exist");
62+
63+
let entries: Vec<_> = std::fs::read_dir(&agent_logs_dir)
64+
.unwrap()
65+
.flatten()
66+
.collect();
67+
assert_eq!(entries.len(), 1, "should have exactly one file");
68+
69+
let filename = entries[0].file_name();
70+
let filename = filename.to_str().unwrap();
71+
assert!(filename.starts_with("AILOG-"), "filename should start with AILOG-");
72+
assert!(filename.ends_with("-test-document.md"), "filename should end with slug");
73+
assert!(filename.contains("-001-"), "filename should contain sequence 001");
74+
}
75+
76+
#[test]
77+
fn test_new_sequence_increments() {
78+
let dir = TempDir::new().unwrap();
79+
setup_devtrail_with_template(dir.path(), "AILOG");
80+
81+
// Create first document
82+
Command::cargo_bin("devtrail")
83+
.unwrap()
84+
.arg("new")
85+
.arg("--doc-type")
86+
.arg("ailog")
87+
.arg("--title")
88+
.arg("First")
89+
.arg(dir.path().to_str().unwrap())
90+
.assert()
91+
.success();
92+
93+
// Create second document
94+
Command::cargo_bin("devtrail")
95+
.unwrap()
96+
.arg("new")
97+
.arg("--doc-type")
98+
.arg("ailog")
99+
.arg("--title")
100+
.arg("Second")
101+
.arg(dir.path().to_str().unwrap())
102+
.assert()
103+
.success();
104+
105+
let agent_logs_dir = dir.path().join(".devtrail/07-ai-audit/agent-logs");
106+
let entries: Vec<String> = std::fs::read_dir(&agent_logs_dir)
107+
.unwrap()
108+
.flatten()
109+
.map(|e| e.file_name().to_str().unwrap().to_string())
110+
.collect();
111+
112+
assert_eq!(entries.len(), 2);
113+
assert!(entries.iter().any(|f| f.contains("-001-")), "should have seq 001");
114+
assert!(entries.iter().any(|f| f.contains("-002-")), "should have seq 002");
115+
}
116+
117+
#[test]
118+
fn test_new_unknown_type() {
119+
let dir = TempDir::new().unwrap();
120+
setup_devtrail_with_template(dir.path(), "AILOG");
121+
122+
let mut cmd = Command::cargo_bin("devtrail").unwrap();
123+
cmd.arg("new")
124+
.arg("--doc-type")
125+
.arg("invalid")
126+
.arg("--title")
127+
.arg("Test")
128+
.arg(dir.path().to_str().unwrap())
129+
.assert()
130+
.failure()
131+
.stderr(predicate::str::contains("Unknown document type"));
132+
}

0 commit comments

Comments
 (0)