Skip to main content

runx_runtime/scaffold/
init.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use super::ScaffoldError;
7use super::ids::{now_iso8601, random_uuid_v4};
8
9#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum InitAction {
12    Project,
13    Global,
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct RunxInitOptions {
18    pub action: InitAction,
19    pub project_dir: PathBuf,
20    pub global_home_dir: PathBuf,
21    pub official_cache_dir: PathBuf,
22    pub prefetch_official: bool,
23    pub generated: InitGeneratedValues,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct InitGeneratedValues {
28    pub project_id: String,
29    pub installation_id: String,
30    pub created_at: String,
31}
32
33impl InitGeneratedValues {
34    #[must_use]
35    pub fn generate() -> Self {
36        Self {
37            project_id: format!("proj_{}", random_uuid_v4()),
38            installation_id: format!("inst_{}", random_uuid_v4()),
39            created_at: now_iso8601(),
40        }
41    }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub struct RunxProjectState {
46    pub version: u8,
47    pub project_id: String,
48    pub created_at: String,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct RunxInstallState {
53    pub version: u8,
54    pub installation_id: String,
55    pub created_at: String,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
59pub struct RunxInitResult {
60    pub action: InitAction,
61    pub created: bool,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub project_dir: Option<PathBuf>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub project_id: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub global_home_dir: Option<PathBuf>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub installation_id: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub official_cache_dir: Option<PathBuf>,
72}
73
74pub fn runx_init(options: &RunxInitOptions) -> Result<RunxInitResult, ScaffoldError> {
75    match options.action {
76        InitAction::Project => {
77            let ensured = ensure_runx_project_state(
78                &options.project_dir,
79                &options.generated.project_id,
80                &options.generated.created_at,
81            )?;
82            let skills_dir = options.project_dir.join("skills");
83            let tools_dir = options.project_dir.join("tools");
84            fs::create_dir_all(&skills_dir).map_err(|source| {
85                ScaffoldError::io("creating skills directory", skills_dir, source)
86            })?;
87            fs::create_dir_all(&tools_dir).map_err(|source| {
88                ScaffoldError::io("creating tools directory", tools_dir, source)
89            })?;
90            Ok(RunxInitResult {
91                action: InitAction::Project,
92                created: ensured.created,
93                project_dir: Some(options.project_dir.clone()),
94                project_id: Some(ensured.state.project_id),
95                global_home_dir: None,
96                installation_id: None,
97                official_cache_dir: None,
98            })
99        }
100        InitAction::Global => {
101            let ensured = ensure_runx_install_state(
102                &options.global_home_dir,
103                &options.generated.installation_id,
104                &options.generated.created_at,
105            )?;
106            if options.prefetch_official {
107                fs::create_dir_all(&options.official_cache_dir).map_err(|source| {
108                    ScaffoldError::io(
109                        "creating official skills cache",
110                        &options.official_cache_dir,
111                        source,
112                    )
113                })?;
114            }
115            Ok(RunxInitResult {
116                action: InitAction::Global,
117                created: ensured.created,
118                project_dir: None,
119                project_id: None,
120                global_home_dir: Some(options.global_home_dir.clone()),
121                installation_id: Some(ensured.state.installation_id),
122                official_cache_dir: options
123                    .prefetch_official
124                    .then(|| options.official_cache_dir.clone()),
125            })
126        }
127    }
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct EnsuredProjectState {
132    pub state: RunxProjectState,
133    pub created: bool,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct EnsuredInstallState {
138    pub state: RunxInstallState,
139    pub created: bool,
140}
141
142pub fn ensure_runx_project_state(
143    project_dir: &Path,
144    project_id: &str,
145    created_at: &str,
146) -> Result<EnsuredProjectState, ScaffoldError> {
147    if let Some(existing) = read_runx_project_state(project_dir)? {
148        return Ok(EnsuredProjectState {
149            state: existing,
150            created: false,
151        });
152    }
153    let state = RunxProjectState {
154        version: 1,
155        project_id: project_id.to_owned(),
156        created_at: created_at.to_owned(),
157    };
158    fs::create_dir_all(project_dir)
159        .map_err(|source| ScaffoldError::io("creating project directory", project_dir, source))?;
160    write_state_json(&project_dir.join("project.json"), &state)?;
161    Ok(EnsuredProjectState {
162        state,
163        created: true,
164    })
165}
166
167pub fn ensure_runx_install_state(
168    global_home_dir: &Path,
169    installation_id: &str,
170    created_at: &str,
171) -> Result<EnsuredInstallState, ScaffoldError> {
172    if let Some(existing) = read_runx_install_state(global_home_dir)? {
173        return Ok(EnsuredInstallState {
174            state: existing,
175            created: false,
176        });
177    }
178    let state = RunxInstallState {
179        version: 1,
180        installation_id: installation_id.to_owned(),
181        created_at: created_at.to_owned(),
182    };
183    fs::create_dir_all(global_home_dir).map_err(|source| {
184        ScaffoldError::io("creating global home directory", global_home_dir, source)
185    })?;
186    write_state_json(&global_home_dir.join("install.json"), &state)?;
187    Ok(EnsuredInstallState {
188        state,
189        created: true,
190    })
191}
192
193fn read_runx_project_state(project_dir: &Path) -> Result<Option<RunxProjectState>, ScaffoldError> {
194    let path = project_dir.join("project.json");
195    match fs::read_to_string(&path) {
196        Ok(contents) => {
197            let state: RunxProjectState = serde_json::from_str(&contents)
198                .map_err(|source| ScaffoldError::json("reading project state", &path, source))?;
199            validate_project_state(path, state).map(Some)
200        }
201        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
202        Err(source) => Err(ScaffoldError::io("reading project state", path, source)),
203    }
204}
205
206fn read_runx_install_state(
207    global_home_dir: &Path,
208) -> Result<Option<RunxInstallState>, ScaffoldError> {
209    let path = global_home_dir.join("install.json");
210    match fs::read_to_string(&path) {
211        Ok(contents) => {
212            let state: RunxInstallState = serde_json::from_str(&contents)
213                .map_err(|source| ScaffoldError::json("reading install state", &path, source))?;
214            validate_install_state(path, state).map(Some)
215        }
216        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
217        Err(source) => Err(ScaffoldError::io("reading install state", path, source)),
218    }
219}
220
221fn validate_project_state(
222    path: PathBuf,
223    state: RunxProjectState,
224) -> Result<RunxProjectState, ScaffoldError> {
225    if state.version != 1 || state.project_id.is_empty() || state.created_at.is_empty() {
226        return Err(ScaffoldError::InvalidState {
227            path,
228            message: "expected version 1, project_id, and created_at".to_owned(),
229        });
230    }
231    Ok(state)
232}
233
234fn validate_install_state(
235    path: PathBuf,
236    state: RunxInstallState,
237) -> Result<RunxInstallState, ScaffoldError> {
238    if state.version != 1 || state.installation_id.is_empty() || state.created_at.is_empty() {
239        return Err(ScaffoldError::InvalidState {
240            path,
241            message: "expected version 1, installation_id, and created_at".to_owned(),
242        });
243    }
244    Ok(state)
245}
246
247fn write_state_json<T: Serialize>(path: &Path, state: &T) -> Result<(), ScaffoldError> {
248    let contents = serde_json::to_string_pretty(state)
249        .map_err(|source| ScaffoldError::json("serializing state", path, source))?;
250    fs::write(path, format!("{contents}\n"))
251        .map_err(|source| ScaffoldError::io("writing state", path, source))?;
252    set_private_file_mode(path)
253}
254
255#[cfg(unix)]
256fn set_private_file_mode(path: &Path) -> Result<(), ScaffoldError> {
257    use std::os::unix::fs::PermissionsExt;
258
259    let permissions = fs::Permissions::from_mode(0o600);
260    fs::set_permissions(path, permissions)
261        .map_err(|source| ScaffoldError::io("setting state permissions", path, source))
262}
263
264#[cfg(not(unix))]
265fn set_private_file_mode(_path: &Path) -> Result<(), ScaffoldError> {
266    Ok(())
267}