1use std::path::{Path, PathBuf};
9
10use anyhow::{bail, Result};
11
12use crate::config::Config;
13use crate::env::Env;
14use crate::workspace::Workspace;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Placement {
18 Workspace,
20 Root,
22}
23
24impl Placement {
25 pub fn key(self) -> &'static str {
26 match self {
27 Placement::Workspace => "EXTRA_APPS",
28 Placement::Root => "ROOT_APPS",
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
34pub struct Found {
35 pub name: String,
36 pub placement: Placement,
37 pub path: PathBuf,
38}
39
40pub fn runnable(dir: &Path) -> bool {
43 let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
44 return false;
45 };
46 let Some(scripts) = text.split("\"scripts\"").nth(1) else {
47 return false;
48 };
49 let block = scripts.split('}').next().unwrap_or("");
50 block.contains("\"dev\"") || block.contains("\"start\"")
51}
52
53pub fn locate(workspace: &Workspace, env: &Env, name: &str) -> Result<Found> {
56 let name = name.trim().trim_end_matches('/');
57 if name.is_empty() || name.contains('/') {
58 bail!("give a folder name, not a path");
59 }
60
61 let frontend = env.get_or("FRONTEND_DIR", "");
62 if !frontend.is_empty() {
63 let in_apps = Path::new(frontend).join("apps").join(name);
64 if in_apps.is_dir() {
65 if !runnable(&in_apps) {
66 bail!("{name} has no dev or start script - nothing to run");
67 }
68 return Ok(Found {
69 name: name.to_string(),
70 placement: Placement::Workspace,
71 path: in_apps,
72 });
73 }
74 }
75
76 let beside = workspace.root.join(name);
77 if beside.is_dir() {
78 if !runnable(&beside) {
79 bail!("{name} has no dev or start script - nothing to run");
80 }
81 return Ok(Found {
82 name: name.to_string(),
83 placement: Placement::Root,
84 path: beside,
85 });
86 }
87
88 bail!("no folder called {name} in the frontend repo's apps/ or beside it")
89}
90
91pub fn first_script(dir: &Path, wanted: &[&str]) -> Option<String> {
93 let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
94 let scripts = text.split("\"scripts\"").nth(1)?;
95 let block = scripts.split('}').next().unwrap_or("");
96 wanted
97 .iter()
98 .find(|name| block.contains(&format!("\"{name}\"")))
99 .map(|name| (*name).to_string())
100}
101
102fn package_manager(dir: &Path) -> &'static str {
103 if dir.join("pnpm-lock.yaml").is_file() {
104 "pnpm"
105 } else if dir.join("yarn.lock").is_file() {
106 "yarn"
107 } else {
108 "npm"
109 }
110}
111
112pub fn start_command(found: &Found) -> String {
118 let wanted: &[&str] = &["dev", "start", "serve"];
119 let script = first_script(&found.path, wanted).unwrap_or_else(|| "dev".to_string());
120
121 match found.placement {
122 Placement::Workspace => format!("pnpm --filter {} run {script}", found.name),
123 Placement::Root => match package_manager(&found.path) {
124 "pnpm" => format!("pnpm {script}"),
125 "yarn" => format!("yarn {script}"),
126 _ => format!("npm run {script}"),
127 },
128 }
129}
130
131pub fn free_port(env: &Env, placement: Placement) -> u16 {
136 let taken: Vec<u16> = env
137 .iter()
138 .filter(|(key, _)| key.ends_with("_PORT"))
139 .filter_map(|(_, value)| value.trim().parse().ok())
140 .collect();
141
142 let start = match placement {
143 Placement::Workspace => 5180,
144 Placement::Root => 4500,
145 };
146 (start..start + 200)
147 .find(|port| !taken.contains(port))
148 .unwrap_or(start)
149}
150
151pub fn already_listed(current: &str, name: &str) -> bool {
152 current
153 .split_whitespace()
154 .any(|entry| entry == name || entry.split(':').nth(1) == Some(name))
155}
156
157pub fn appended(current: &str, name: &str) -> String {
159 let mut entries: Vec<&str> = current.split_whitespace().collect();
160 entries.push(name);
161 entries.join(" ")
162}
163
164pub fn record(workspace: &Workspace, found: &Found) -> Result<Vec<String>> {
171 let env = Env::load(&workspace.env_path())?;
172 let list_key = found.placement.key();
173 let current = env.get_or(list_key, "").to_string();
174
175 if already_listed(¤t, &found.name) {
176 return Ok(vec![format!("{list_key} already lists {}", found.name)]);
177 }
178
179 let key = crate::config::key_of(&found.name);
180 let port = free_port(&env, found.placement);
181 let command = start_command(found);
182
183 let settings = vec![
184 (list_key.to_string(), appended(¤t, &found.name)),
185 (format!("{key}_PORT"), port.to_string()),
186 (format!("{key}_CMD"), command),
187 ];
188
189 for (name, value) in &settings {
190 crate::doctor::set_env_key(&workspace.env_path(), name, value, NOTE)?;
191 }
192
193 let config_path = workspace.config_path();
194 if config_path.is_file() {
195 let mut config = Config::load(&config_path)?;
196 for (name, value) in &settings {
197 let parsed = if name.ends_with("_PORT") {
199 value
200 .parse::<u64>()
201 .map(|number| serde_json::Value::Number(number.into()))
202 .unwrap_or_else(|_| serde_json::Value::String(value.clone()))
203 } else {
204 serde_json::Value::String(value.clone())
205 };
206 config.set(name, parsed);
207 }
208 config.save(&config_path)?;
209 }
210
211 Ok(settings
212 .into_iter()
213 .map(|(name, value)| format!("{name}={value}"))
214 .collect())
215}
216
217const NOTE: &str =
218 "Added by `rst add`. Apps beside the frontend repo get their own mount and\ncommand; apps inside its apps/ share the frontend mount.";
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn workspace_at(root: &Path) -> Workspace {
225 Workspace {
226 root: root.to_path_buf(),
227 run_dir: root.join(".run"),
228 }
229 }
230
231 fn env_with(text: &str, root: &Path) -> Env {
232 let path = root.join(".run").join(".env");
233 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
234 std::fs::write(&path, text).unwrap();
235 Env::load(&path).unwrap()
236 }
237
238 fn app(dir: &Path, scripts: &str) {
239 std::fs::create_dir_all(dir).unwrap();
240 std::fs::write(dir.join("package.json"), scripts).unwrap();
241 }
242
243 #[test]
244 fn a_folder_beside_the_repo_becomes_a_root_app() {
245 let tmp = tempfile::tempdir().unwrap();
246 let root = tmp.path();
247 app(&root.join("seeder"), r#"{"scripts":{"dev":"node server.js"}}"#);
248 let env = env_with("FRONTEND_DIR=platform\n", root);
249
250 let found = locate(&workspace_at(root), &env, "seeder").unwrap();
251
252 assert_eq!(found.placement, Placement::Root);
253 assert_eq!(found.placement.key(), "ROOT_APPS");
254 }
255
256 #[test]
257 fn a_folder_in_the_repos_apps_becomes_an_extra_app() {
258 let tmp = tempfile::tempdir().unwrap();
259 let root = tmp.path();
260 app(&root.join("platform/apps/reports"), r#"{"scripts":{"dev":"vite"}}"#);
261 let env = env_with(&format!("FRONTEND_DIR={}/platform\n", root.display()), root);
262
263 let found = locate(&workspace_at(root), &env, "reports").unwrap();
264
265 assert_eq!(found.placement, Placement::Workspace);
266 assert_eq!(found.placement.key(), "EXTRA_APPS");
267 }
268
269 #[test]
270 fn the_repos_apps_win_over_a_folder_of_the_same_name_beside_it() {
271 let tmp = tempfile::tempdir().unwrap();
272 let root = tmp.path();
273 app(&root.join("platform/apps/tools"), r#"{"scripts":{"dev":"vite"}}"#);
274 app(&root.join("tools"), r#"{"scripts":{"dev":"node ."}}"#);
275 let env = env_with(&format!("FRONTEND_DIR={}/platform\n", root.display()), root);
276
277 assert_eq!(
278 locate(&workspace_at(root), &env, "tools").unwrap().placement,
279 Placement::Workspace
280 );
281 }
282
283 #[test]
284 fn a_folder_with_nothing_to_run_is_refused() {
285 let tmp = tempfile::tempdir().unwrap();
286 let root = tmp.path();
287 app(&root.join("shared"), r#"{"scripts":{"build":"tsc"}}"#);
288 let env = env_with("", root);
289
290 let error = locate(&workspace_at(root), &env, "shared").unwrap_err().to_string();
291
292 assert!(error.contains("no dev or start script"), "{error}");
293 }
294
295 #[test]
296 fn an_unknown_folder_says_where_it_looked() {
297 let tmp = tempfile::tempdir().unwrap();
298 let env = env_with("", tmp.path());
299
300 let error = locate(&workspace_at(tmp.path()), &env, "ghost").unwrap_err().to_string();
301
302 assert!(error.contains("apps/"), "{error}");
303 }
304
305 #[test]
306 fn a_path_is_refused_rather_than_guessed_at() {
307 let tmp = tempfile::tempdir().unwrap();
308 let env = env_with("", tmp.path());
309
310 assert!(locate(&workspace_at(tmp.path()), &env, "a/b").is_err());
311 }
312
313 #[test]
314 fn appending_keeps_what_is_there() {
315 assert_eq!(appended("one two", "three"), "one two three");
316 assert_eq!(appended("", "one"), "one");
317 }
318
319 #[test]
320 fn a_name_dir_pair_counts_as_listed() {
321 assert!(already_listed("seeder:althaqeel-seeder", "althaqeel-seeder"));
322 assert!(already_listed("tools seeder", "seeder"));
323 assert!(!already_listed("tools", "seeder"));
324 }
325
326 #[test]
327 fn recording_writes_the_env_and_is_idempotent() {
328 let tmp = tempfile::tempdir().unwrap();
329 let root = tmp.path();
330 app(&root.join("seeder"), r#"{"scripts":{"dev":"node server.js"}}"#);
331 let env = env_with("FRONTEND_DIR=platform\n", root);
332 let workspace = workspace_at(root);
333 let found = locate(&workspace, &env, "seeder").unwrap();
334
335 record(&workspace, &found).unwrap();
336 let once = std::fs::read_to_string(workspace.env_path()).unwrap();
337 let second = record(&workspace, &found).unwrap();
338
339 assert!(once.contains("ROOT_APPS=seeder"), "{once}");
340 assert!(once.contains("SEEDER_PORT="), "no port written: {once}");
341 assert!(once.contains("SEEDER_CMD="), "no command written: {once}");
342 assert!(second.iter().any(|line| line.contains("already lists")));
343 assert_eq!(once, std::fs::read_to_string(workspace.env_path()).unwrap());
344 }
345}
346
347#[cfg(test)]
348mod quoting_tests {
349 use super::*;
350
351 #[test]
352 fn a_second_app_is_written_quoted() {
353 let tmp = tempfile::tempdir().unwrap();
355 let root = tmp.path();
356 std::fs::create_dir_all(root.join(".run")).unwrap();
357 let env_path = root.join(".run").join(".env");
358 std::fs::write(&env_path, "ROOT_APPS=seeder\n").unwrap();
359
360 crate::doctor::set_env_key(&env_path, "ROOT_APPS", "seeder tools", "why").unwrap();
361
362 let text = std::fs::read_to_string(&env_path).unwrap();
363 assert!(text.contains("ROOT_APPS=\"seeder tools\""), "{text}");
364 assert!(crate::doctor::unquoted_values(&text).is_empty());
365 }
366}
367
368#[cfg(test)]
369mod config_tests {
370 use super::*;
371
372 fn found_at(path: &Path, name: &str, placement: Placement) -> Found {
373 Found { name: name.to_string(), placement, path: path.to_path_buf() }
374 }
375
376 fn write_app(dir: &Path, manifest: &str, lock: Option<&str>) {
377 std::fs::create_dir_all(dir).unwrap();
378 std::fs::write(dir.join("package.json"), manifest).unwrap();
379 if let Some(lock) = lock {
380 std::fs::write(dir.join(lock), "").unwrap();
381 }
382 }
383
384 #[test]
385 fn a_workspace_app_is_started_through_pnpm_filter() {
386 let tmp = tempfile::tempdir().unwrap();
388 write_app(tmp.path(), r#"{"scripts":{"dev":"vite"}}"#, None);
389
390 let command = start_command(&found_at(tmp.path(), "reports", Placement::Workspace));
391
392 assert_eq!(command, "pnpm --filter reports run dev");
393 }
394
395 #[test]
396 fn a_root_app_runs_its_own_package_manager() {
397 let tmp = tempfile::tempdir().unwrap();
398 write_app(tmp.path(), r#"{"scripts":{"dev":"node server.js"}}"#, Some("package-lock.json"));
399
400 assert_eq!(start_command(&found_at(tmp.path(), "seeder", Placement::Root)), "npm run dev");
401 }
402
403 #[test]
404 fn a_root_app_with_a_pnpm_lock_uses_pnpm() {
405 let tmp = tempfile::tempdir().unwrap();
406 write_app(tmp.path(), r#"{"scripts":{"dev":"node ."}}"#, Some("pnpm-lock.yaml"));
407
408 assert_eq!(start_command(&found_at(tmp.path(), "tools", Placement::Root)), "pnpm dev");
409 }
410
411 #[test]
412 fn start_is_used_when_there_is_no_dev() {
413 let tmp = tempfile::tempdir().unwrap();
414 write_app(tmp.path(), r#"{"scripts":{"start":"node ."}}"#, None);
415
416 assert_eq!(start_command(&found_at(tmp.path(), "api", Placement::Root)), "npm run start");
417 }
418
419 #[test]
420 fn the_port_avoids_one_already_claimed() {
421 let tmp = tempfile::tempdir().unwrap();
422 let path = tmp.path().join(".env");
423 std::fs::write(&path, "SEEDER_PORT=4500\nOTHER_PORT=4501\n").unwrap();
424 let env = Env::load(&path).unwrap();
425
426 assert_eq!(free_port(&env, Placement::Root), 4502);
427 }
428
429 #[test]
430 fn workspace_and_root_apps_start_from_different_ranges() {
431 let tmp = tempfile::tempdir().unwrap();
432 let path = tmp.path().join(".env");
433 std::fs::write(&path, "").unwrap();
434 let env = Env::load(&path).unwrap();
435
436 assert_eq!(free_port(&env, Placement::Root), 4500);
437 assert_eq!(free_port(&env, Placement::Workspace), 5180);
438 }
439}