runx_runtime/scaffold/
new.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use super::ScaffoldError;
7use super::templates::scaffold_package_files;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct RunxNewOptions {
11 pub name: String,
12 pub directory: PathBuf,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
16pub struct RunxNewResult {
17 pub name: String,
18 pub directory: PathBuf,
19 pub files: Vec<String>,
20 pub next_steps: Vec<String>,
21}
22
23pub fn scaffold_runx_package(options: &RunxNewOptions) -> Result<RunxNewResult, ScaffoldError> {
24 let name = sanitize_runx_package_name(&options.name);
25 let root = lexical_absolute(&options.directory)?;
26 assert_writable_scaffold_target(&root)?;
27
28 let writes = scaffold_package_files(&name);
29
30 fs::create_dir_all(&root)
31 .map_err(|source| ScaffoldError::io("creating scaffold root", &root, source))?;
32 for file in &writes {
33 write_file(&root, &file.relative_path, &file.contents)?;
34 }
35
36 Ok(RunxNewResult {
37 name,
38 directory: root.clone(),
39 files: writes.into_iter().map(|file| file.relative_path).collect(),
40 next_steps: vec![
41 format!("cd {}", root.display()),
42 "runx harness . --json".to_owned(),
43 "runx skill . --input message=hello --json".to_owned(),
44 ],
45 })
46}
47
48#[must_use]
49pub fn sanitize_runx_package_name(value: &str) -> String {
50 let sanitized = trim_boundary_separators(&replace_runs(
51 &value.trim().to_lowercase(),
52 |character| {
53 character.is_ascii_lowercase()
54 || character.is_ascii_digit()
55 || matches!(character, '_' | '.' | '-')
56 },
57 '-',
58 ));
59 if sanitized.is_empty() {
60 "runx-package".to_owned()
61 } else {
62 sanitized
63 }
64}
65
66fn assert_writable_scaffold_target(root: &Path) -> Result<(), ScaffoldError> {
67 match fs::read_dir(root) {
68 Ok(mut entries) => match entries.next() {
69 Some(Ok(_)) => Err(ScaffoldError::NonEmptyTarget {
70 path: root.to_path_buf(),
71 }),
72 Some(Err(source)) => Err(ScaffoldError::io("reading scaffold target", root, source)),
73 None => Ok(()),
74 },
75 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
76 Err(source) => Err(ScaffoldError::io("reading scaffold target", root, source)),
77 }
78}
79
80fn write_file(root: &Path, relative_path: &str, contents: &str) -> Result<(), ScaffoldError> {
81 let file_path = root.join(relative_path);
82 if let Some(parent) = file_path.parent() {
83 fs::create_dir_all(parent)
84 .map_err(|source| ScaffoldError::io("creating scaffold directory", parent, source))?;
85 }
86 let mut writable = contents.to_owned();
87 if !writable.ends_with('\n') {
88 writable.push('\n');
89 }
90 fs::write(&file_path, writable)
91 .map_err(|source| ScaffoldError::io("writing scaffold file", file_path, source))
92}
93
94fn lexical_absolute(path: &Path) -> Result<PathBuf, ScaffoldError> {
95 if path.is_absolute() {
96 Ok(path.to_path_buf())
97 } else {
98 std::env::current_dir()
99 .map(|cwd| cwd.join(path))
100 .map_err(|source| ScaffoldError::io("resolving current directory", ".", source))
101 }
102}
103
104fn replace_runs(value: &str, keep: impl Fn(char) -> bool, replacement: char) -> String {
105 let mut output = String::with_capacity(value.len());
106 let mut replacing = false;
107 for character in value.chars() {
108 if keep(character) {
109 output.push(character);
110 replacing = false;
111 } else if !replacing {
112 output.push(replacement);
113 replacing = true;
114 }
115 }
116 output
117}
118
119fn trim_boundary_separators(value: &str) -> String {
120 value
121 .trim_matches(|character| matches!(character, '.' | '_' | '-'))
122 .to_owned()
123}