Skip to main content

run_stack/
add.rs

1//! `rst add <folder>` - register a folder as an app without answering the
2//! whole init questionnaire.
3//!
4//! Where it lands depends on where it lives: inside the frontend repo's apps/
5//! it joins EXTRA_APPS and shares that bind mount, and beside the repo it joins
6//! ROOT_APPS and gets a mount of its own.
7
8use 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    /// Inside FRONTEND_DIR/apps - part of the workspace already.
19    Workspace,
20    /// A sibling of the frontend repo.
21    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
40/// Something to run. A folder with no manifest, or a library with only build
41/// and test scripts, is not an app.
42pub 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
53/// Resolve a folder name the way a person would mean it: the frontend repo's
54/// apps/ first, then beside the repo.
55pub 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
91/// The first of `wanted` that the folder's package.json defines.
92pub 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
112/// The command that starts the app.
113///
114/// An app inside the repo is started through the workspace, the way every
115/// other entry in EXTRA_APPS is. One beside the repo has its own directory
116/// mounted, so it runs its own package manager there.
117pub 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
131/// A port nothing else in the workspace has claimed.
132///
133/// Ports are the one setting a person cannot be asked for here, and a
134/// collision only shows up later as a container that will not bind.
135pub 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
157/// Append to a space separated list, leaving the order it already has.
158pub 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
164/// Write the new app to both files: .env is what compose reads, and
165/// run.config.toml is what init rewrites .env from. Writing one alone means
166/// the next `init --update` either misses it or drops it.
167///
168/// The list alone is not enough - an app also needs the port and command
169/// every other app in the file has.
170pub 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(&current, &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(&current, &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            // A port is a number in the config, like every other port there.
198            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        // Unquoted, `ROOT_APPS=seeder tools` runs `tools` when .env is sourced.
354        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        // Matching the shape every other EXTRA_APPS entry already has.
387        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}