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}