Skip to main content

runx_runtime/scaffold/
new.rs

1use 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}