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    /// What the service is called: what you type after `rst up`.
36    pub name: String,
37    /// The folder on disk, which is not always what you would call the app.
38    pub dir: String,
39    pub placement: Placement,
40    pub path: PathBuf,
41}
42
43impl Found {
44    /// `name:dir` when they differ, so the service keeps the short name while
45    /// the mount still points at the right folder.
46    pub fn entry(&self) -> String {
47        if self.name == self.dir {
48            self.name.clone()
49        } else {
50            format!("{}:{}", self.name, self.dir)
51        }
52    }
53}
54
55/// A folder beside the repo is usually named for the project it belongs to -
56/// `althaqeel-seeder` in the `althaqeel` workspace. The project prefix says
57/// nothing you do not already know, so the service is just `seeder`.
58///
59/// Apps inside the repo keep their folder name: that is their workspace
60/// package name, and `pnpm --filter` needs it exactly.
61pub fn service_name(workspace_root: &Path, folder: &str, placement: Placement) -> String {
62    if placement == Placement::Workspace {
63        return folder.to_string();
64    }
65    let Some(project) = workspace_root.file_name().map(|name| name.to_string_lossy().to_string())
66    else {
67        return folder.to_string();
68    };
69
70    match folder.strip_prefix(&format!("{project}-")) {
71        Some(short) if !short.is_empty() => short.to_string(),
72        _ => folder.to_string(),
73    }
74}
75
76/// Something to run. A folder with no manifest, or a library with only build
77/// and test scripts, is not an app.
78pub fn runnable(dir: &Path) -> bool {
79    let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
80        return false;
81    };
82    let Some(scripts) = text.split("\"scripts\"").nth(1) else {
83        return false;
84    };
85    let block = scripts.split('}').next().unwrap_or("");
86    block.contains("\"dev\"") || block.contains("\"start\"")
87}
88
89/// Resolve a folder name the way a person would mean it: the frontend repo's
90/// apps/ first, then beside the repo.
91pub fn locate(workspace: &Workspace, env: &Env, name: &str) -> Result<Found> {
92    let name = name.trim().trim_end_matches('/');
93    if name.is_empty() || name.contains('/') {
94        bail!("give a folder name, not a path");
95    }
96
97    let frontend = env.get_or("FRONTEND_DIR", "");
98    if !frontend.is_empty() {
99        let in_apps = Path::new(frontend).join("apps").join(name);
100        if in_apps.is_dir() {
101            if !runnable(&in_apps) {
102                bail!("{name} has no dev or start script - nothing to run");
103            }
104            return Ok(Found {
105                name: name.to_string(),
106                dir: name.to_string(),
107                placement: Placement::Workspace,
108                path: in_apps,
109            });
110        }
111    }
112
113    let beside = workspace.root.join(name);
114    if beside.is_dir() {
115        if !runnable(&beside) {
116            bail!("{name} has no dev or start script - nothing to run");
117        }
118        return Ok(Found {
119            name: service_name(&workspace.root, name, Placement::Root),
120            dir: name.to_string(),
121            placement: Placement::Root,
122            path: beside,
123        });
124    }
125
126    bail!("no folder called {name} in the frontend repo's apps/ or beside it")
127}
128
129/// The first of `wanted` that the folder's package.json defines.
130pub fn first_script(dir: &Path, wanted: &[&str]) -> Option<String> {
131    let text = std::fs::read_to_string(dir.join("package.json")).ok()?;
132    let scripts = text.split("\"scripts\"").nth(1)?;
133    let block = scripts.split('}').next().unwrap_or("");
134    wanted
135        .iter()
136        .find(|name| block.contains(&format!("\"{name}\"")))
137        .map(|name| (*name).to_string())
138}
139
140fn package_manager(dir: &Path) -> &'static str {
141    if dir.join("pnpm-lock.yaml").is_file() {
142        "pnpm"
143    } else if dir.join("yarn.lock").is_file() {
144        "yarn"
145    } else {
146        "npm"
147    }
148}
149
150/// The command that starts the app.
151///
152/// An app inside the repo is started through the workspace, the way every
153/// other entry in EXTRA_APPS is. One beside the repo has its own directory
154/// mounted, so it runs its own package manager there.
155pub fn start_command(found: &Found) -> String {
156    let wanted: &[&str] = &["dev", "start", "serve"];
157    let script = first_script(&found.path, wanted).unwrap_or_else(|| "dev".to_string());
158
159    match found.placement {
160        Placement::Workspace => format!("pnpm --filter {} run {script}", found.name),
161        Placement::Root => match package_manager(&found.path) {
162            "pnpm" => format!("pnpm {script}"),
163            "yarn" => format!("yarn {script}"),
164            _ => format!("npm run {script}"),
165        },
166    }
167}
168
169/// A port nothing else in the workspace has claimed.
170///
171/// Ports are the one setting a person cannot be asked for here, and a
172/// collision only shows up later as a container that will not bind.
173pub fn free_port(env: &Env, placement: Placement) -> u16 {
174    let taken: Vec<u16> = env
175        .iter()
176        .filter(|(key, _)| key.ends_with("_PORT"))
177        .filter_map(|(_, value)| value.trim().parse().ok())
178        .collect();
179
180    let start = match placement {
181        Placement::Workspace => 5180,
182        Placement::Root => 4500,
183    };
184    (start..start + 200)
185        .find(|port| !taken.contains(port))
186        .unwrap_or(start)
187}
188
189pub fn already_listed(current: &str, name: &str) -> bool {
190    current
191        .split_whitespace()
192        .any(|entry| entry == name || entry.split(':').nth(1) == Some(name))
193}
194
195/// Append to a space separated list, leaving the order it already has.
196pub fn appended(current: &str, name: &str) -> String {
197    let mut entries: Vec<&str> = current.split_whitespace().collect();
198    entries.push(name);
199    entries.join(" ")
200}
201
202/// Write the new app to both files: .env is what compose reads, and
203/// run.config.toml is what init rewrites .env from. Writing one alone means
204/// the next `init --update` either misses it or drops it.
205///
206/// Listing the app is not enough - it also needs the port and command every
207/// other app in the file has. An app already listed but missing either is
208/// completed rather than refused, so a half-written entry converges.
209pub fn record(workspace: &Workspace, found: &Found) -> Result<Vec<String>> {
210    let env = Env::load(&workspace.env_path())?;
211    let list_key = found.placement.key();
212    let current = env.get_or(list_key, "").to_string();
213
214    // An app listed as `name:dir` keys its settings on the name half, whichever
215    // half was typed here.
216    let declared = find_declared(&env, &found.name);
217    let name = declared
218        .as_ref()
219        .map(|entry| entry.name.clone())
220        .unwrap_or_else(|| found.name.clone());
221    let key = crate::config::key_of(&name);
222
223    let mut settings: Vec<(String, String)> = Vec::new();
224    let mut notes: Vec<String> = Vec::new();
225
226    if declared.is_none() {
227        settings.push((list_key.to_string(), appended(&current, &found.entry())));
228    } else {
229        notes.push(format!("{list_key} already lists {}", found.name));
230    }
231
232    // Only fill what is missing: a command written by hand is the point of
233    // writing it by hand.
234    let port_key = format!("{key}_PORT");
235    if env.get(&port_key).unwrap_or("").trim().is_empty() {
236        settings.push((port_key, free_port(&env, found.placement).to_string()));
237    } else {
238        notes.push(format!("{port_key} already set"));
239    }
240
241    let cmd_key = format!("{key}_CMD");
242    if env.get(&cmd_key).unwrap_or("").trim().is_empty() {
243        settings.push((cmd_key, start_command(found)));
244    } else {
245        notes.push(format!("{cmd_key} already set"));
246    }
247
248    if settings.is_empty() {
249        notes.push("nothing left to add".to_string());
250        return Ok(notes);
251    }
252
253    for (name, value) in &settings {
254        crate::doctor::set_env_key(&workspace.env_path(), name, value, NOTE)?;
255    }
256
257    let config_path = workspace.config_path();
258    if config_path.is_file() {
259        let mut config = Config::load(&config_path)?;
260        for (name, value) in &settings {
261            // A port is a number in the config, like every other port there.
262            let parsed = if name.ends_with("_PORT") {
263                value
264                    .parse::<u64>()
265                    .map(|number| serde_json::Value::Number(number.into()))
266                    .unwrap_or_else(|_| serde_json::Value::String(value.clone()))
267            } else {
268                serde_json::Value::String(value.clone())
269            };
270            config.set(name, parsed);
271        }
272        config.save(&config_path)?;
273    }
274
275    let mut done: Vec<String> = settings
276        .into_iter()
277        .map(|(name, value)| format!("{name}={value}"))
278        .collect();
279    done.extend(notes);
280    Ok(done)
281}
282
283/// An app as it is declared: the entry in the list, plus the name its
284/// settings are keyed on. For a `name:dir` pair those differ.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Declared {
287    pub entry: String,
288    pub name: String,
289    pub placement: Placement,
290}
291
292/// Find a declared app by either half of a `name:dir` pair, so removing it
293/// works whichever half the person remembers.
294pub fn find_declared(env: &Env, wanted: &str) -> Option<Declared> {
295    let wanted = wanted.trim().trim_end_matches('/');
296    for placement in [Placement::Workspace, Placement::Root] {
297        for entry in env.get_or(placement.key(), "").split_whitespace() {
298            let name = entry.split(':').next().unwrap_or(entry);
299            let dir = entry.split(':').nth(1).unwrap_or(name);
300            if wanted == entry || wanted == name || wanted == dir {
301                return Some(Declared {
302                    entry: entry.to_string(),
303                    name: name.to_string(),
304                    placement,
305                });
306            }
307        }
308    }
309    None
310}
311
312pub fn without(current: &str, entry: &str) -> String {
313    current
314        .split_whitespace()
315        .filter(|candidate| *candidate != entry)
316        .collect::<Vec<_>>()
317        .join(" ")
318}
319
320/// Drop the app from the list and take its port and command with it, from
321/// both files. Leaving the settings behind would put them back on the next
322/// app that happens to take the same name.
323pub fn forget(workspace: &Workspace, declared: &Declared) -> Result<Vec<String>> {
324    let env = Env::load(&workspace.env_path())?;
325    let list_key = declared.placement.key();
326    let remaining = without(env.get_or(list_key, ""), &declared.entry);
327
328    let key = crate::config::key_of(&declared.name);
329    let settings = [format!("{key}_PORT"), format!("{key}_CMD")];
330
331    crate::doctor::set_env_key(&workspace.env_path(), list_key, &remaining, NOTE)?;
332    for name in &settings {
333        crate::doctor::remove_env_key(&workspace.env_path(), name)?;
334    }
335
336    let config_path = workspace.config_path();
337    if config_path.is_file() {
338        let mut config = Config::load(&config_path)?;
339        config.set(list_key, serde_json::Value::String(remaining.clone()));
340        for name in &settings {
341            config.remove(name);
342        }
343        config.save(&config_path)?;
344    }
345
346    let mut done = vec![format!("{list_key}={remaining}")];
347    done.extend(settings.iter().map(|name| format!("dropped {name}")));
348    Ok(done)
349}
350
351const NOTE: &str =
352    "Added by `rst add`. Apps beside the frontend repo get their own mount and\ncommand; apps inside its apps/ share the frontend mount.";
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn workspace_at(root: &Path) -> Workspace {
359        Workspace {
360            root: root.to_path_buf(),
361            run_dir: root.join(".run"),
362        }
363    }
364
365    fn env_with(text: &str, root: &Path) -> Env {
366        let path = root.join(".run").join(".env");
367        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
368        std::fs::write(&path, text).unwrap();
369        Env::load(&path).unwrap()
370    }
371
372    fn app(dir: &Path, scripts: &str) {
373        std::fs::create_dir_all(dir).unwrap();
374        std::fs::write(dir.join("package.json"), scripts).unwrap();
375    }
376
377    #[test]
378    fn a_folder_beside_the_repo_becomes_a_root_app() {
379        let tmp = tempfile::tempdir().unwrap();
380        let root = tmp.path();
381        app(&root.join("seeder"), r#"{"scripts":{"dev":"node server.js"}}"#);
382        let env = env_with("FRONTEND_DIR=platform\n", root);
383
384        let found = locate(&workspace_at(root), &env, "seeder").unwrap();
385
386        assert_eq!(found.placement, Placement::Root);
387        assert_eq!(found.placement.key(), "ROOT_APPS");
388    }
389
390    #[test]
391    fn a_folder_in_the_repos_apps_becomes_an_extra_app() {
392        let tmp = tempfile::tempdir().unwrap();
393        let root = tmp.path();
394        app(&root.join("platform/apps/reports"), r#"{"scripts":{"dev":"vite"}}"#);
395        let env = env_with(&format!("FRONTEND_DIR={}/platform\n", root.display()), root);
396
397        let found = locate(&workspace_at(root), &env, "reports").unwrap();
398
399        assert_eq!(found.placement, Placement::Workspace);
400        assert_eq!(found.placement.key(), "EXTRA_APPS");
401    }
402
403    #[test]
404    fn the_repos_apps_win_over_a_folder_of_the_same_name_beside_it() {
405        let tmp = tempfile::tempdir().unwrap();
406        let root = tmp.path();
407        app(&root.join("platform/apps/tools"), r#"{"scripts":{"dev":"vite"}}"#);
408        app(&root.join("tools"), r#"{"scripts":{"dev":"node ."}}"#);
409        let env = env_with(&format!("FRONTEND_DIR={}/platform\n", root.display()), root);
410
411        assert_eq!(
412            locate(&workspace_at(root), &env, "tools").unwrap().placement,
413            Placement::Workspace
414        );
415    }
416
417    #[test]
418    fn a_folder_with_nothing_to_run_is_refused() {
419        let tmp = tempfile::tempdir().unwrap();
420        let root = tmp.path();
421        app(&root.join("shared"), r#"{"scripts":{"build":"tsc"}}"#);
422        let env = env_with("", root);
423
424        let error = locate(&workspace_at(root), &env, "shared").unwrap_err().to_string();
425
426        assert!(error.contains("no dev or start script"), "{error}");
427    }
428
429    #[test]
430    fn an_unknown_folder_says_where_it_looked() {
431        let tmp = tempfile::tempdir().unwrap();
432        let env = env_with("", tmp.path());
433
434        let error = locate(&workspace_at(tmp.path()), &env, "ghost").unwrap_err().to_string();
435
436        assert!(error.contains("apps/"), "{error}");
437    }
438
439    #[test]
440    fn a_path_is_refused_rather_than_guessed_at() {
441        let tmp = tempfile::tempdir().unwrap();
442        let env = env_with("", tmp.path());
443
444        assert!(locate(&workspace_at(tmp.path()), &env, "a/b").is_err());
445    }
446
447    #[test]
448    fn appending_keeps_what_is_there() {
449        assert_eq!(appended("one two", "three"), "one two three");
450        assert_eq!(appended("", "one"), "one");
451    }
452
453    #[test]
454    fn a_name_dir_pair_counts_as_listed() {
455        assert!(already_listed("seeder:althaqeel-seeder", "althaqeel-seeder"));
456        assert!(already_listed("tools seeder", "seeder"));
457        assert!(!already_listed("tools", "seeder"));
458    }
459
460    #[test]
461    fn recording_writes_the_env_and_is_idempotent() {
462        let tmp = tempfile::tempdir().unwrap();
463        let root = tmp.path();
464        app(&root.join("seeder"), r#"{"scripts":{"dev":"node server.js"}}"#);
465        let env = env_with("FRONTEND_DIR=platform\n", root);
466        let workspace = workspace_at(root);
467        let found = locate(&workspace, &env, "seeder").unwrap();
468
469        record(&workspace, &found).unwrap();
470        let once = std::fs::read_to_string(workspace.env_path()).unwrap();
471        let second = record(&workspace, &found).unwrap();
472
473        assert!(once.contains("ROOT_APPS=seeder"), "{once}");
474        assert!(once.contains("SEEDER_PORT="), "no port written: {once}");
475        assert!(once.contains("SEEDER_CMD="), "no command written: {once}");
476        assert!(second.iter().any(|line| line.contains("already lists")));
477        assert_eq!(once, std::fs::read_to_string(workspace.env_path()).unwrap());
478    }
479}
480
481#[cfg(test)]
482mod quoting_tests {
483    use super::*;
484
485    #[test]
486    fn a_second_app_is_written_quoted() {
487        // Unquoted, `ROOT_APPS=seeder tools` runs `tools` when .env is sourced.
488        let tmp = tempfile::tempdir().unwrap();
489        let root = tmp.path();
490        std::fs::create_dir_all(root.join(".run")).unwrap();
491        let env_path = root.join(".run").join(".env");
492        std::fs::write(&env_path, "ROOT_APPS=seeder\n").unwrap();
493
494        crate::doctor::set_env_key(&env_path, "ROOT_APPS", "seeder tools", "why").unwrap();
495
496        let text = std::fs::read_to_string(&env_path).unwrap();
497        assert!(text.contains("ROOT_APPS=\"seeder tools\""), "{text}");
498        assert!(crate::doctor::unquoted_values(&text).is_empty());
499    }
500}
501
502#[cfg(test)]
503mod config_tests {
504    use super::*;
505
506    fn found_at(path: &Path, name: &str, placement: Placement) -> Found {
507        Found { name: name.to_string(), dir: name.to_string(), placement, path: path.to_path_buf() }
508    }
509
510    fn write_app(dir: &Path, manifest: &str, lock: Option<&str>) {
511        std::fs::create_dir_all(dir).unwrap();
512        std::fs::write(dir.join("package.json"), manifest).unwrap();
513        if let Some(lock) = lock {
514            std::fs::write(dir.join(lock), "").unwrap();
515        }
516    }
517
518    #[test]
519    fn a_workspace_app_is_started_through_pnpm_filter() {
520        // Matching the shape every other EXTRA_APPS entry already has.
521        let tmp = tempfile::tempdir().unwrap();
522        write_app(tmp.path(), r#"{"scripts":{"dev":"vite"}}"#, None);
523
524        let command = start_command(&found_at(tmp.path(), "reports", Placement::Workspace));
525
526        assert_eq!(command, "pnpm --filter reports run dev");
527    }
528
529    #[test]
530    fn a_root_app_runs_its_own_package_manager() {
531        let tmp = tempfile::tempdir().unwrap();
532        write_app(tmp.path(), r#"{"scripts":{"dev":"node server.js"}}"#, Some("package-lock.json"));
533
534        assert_eq!(start_command(&found_at(tmp.path(), "seeder", Placement::Root)), "npm run dev");
535    }
536
537    #[test]
538    fn a_root_app_with_a_pnpm_lock_uses_pnpm() {
539        let tmp = tempfile::tempdir().unwrap();
540        write_app(tmp.path(), r#"{"scripts":{"dev":"node ."}}"#, Some("pnpm-lock.yaml"));
541
542        assert_eq!(start_command(&found_at(tmp.path(), "tools", Placement::Root)), "pnpm dev");
543    }
544
545    #[test]
546    fn start_is_used_when_there_is_no_dev() {
547        let tmp = tempfile::tempdir().unwrap();
548        write_app(tmp.path(), r#"{"scripts":{"start":"node ."}}"#, None);
549
550        assert_eq!(start_command(&found_at(tmp.path(), "api", Placement::Root)), "npm run start");
551    }
552
553    #[test]
554    fn the_port_avoids_one_already_claimed() {
555        let tmp = tempfile::tempdir().unwrap();
556        let path = tmp.path().join(".env");
557        std::fs::write(&path, "SEEDER_PORT=4500\nOTHER_PORT=4501\n").unwrap();
558        let env = Env::load(&path).unwrap();
559
560        assert_eq!(free_port(&env, Placement::Root), 4502);
561    }
562
563    #[test]
564    fn workspace_and_root_apps_start_from_different_ranges() {
565        let tmp = tempfile::tempdir().unwrap();
566        let path = tmp.path().join(".env");
567        std::fs::write(&path, "").unwrap();
568        let env = Env::load(&path).unwrap();
569
570        assert_eq!(free_port(&env, Placement::Root), 4500);
571        assert_eq!(free_port(&env, Placement::Workspace), 5180);
572    }
573}
574
575#[cfg(test)]
576mod remove_tests {
577    use super::*;
578
579    fn env_from(text: &str) -> (tempfile::TempDir, Env) {
580        let tmp = tempfile::tempdir().unwrap();
581        std::fs::create_dir_all(tmp.path().join(".run")).unwrap();
582        let path = tmp.path().join(".run").join(".env");
583        std::fs::write(&path, text).unwrap();
584        let env = Env::load(&path).unwrap();
585        (tmp, env)
586    }
587
588    fn workspace_at(root: &Path) -> Workspace {
589        Workspace { root: root.to_path_buf(), run_dir: root.join(".run") }
590    }
591
592    #[test]
593    fn either_half_of_a_name_dir_pair_finds_it() {
594        let (_tmp, env) = env_from("ROOT_APPS=seeder:althaqeel-seeder\n");
595
596        for wanted in ["seeder", "althaqeel-seeder", "seeder:althaqeel-seeder"] {
597            let found = find_declared(&env, wanted).expect(wanted);
598            assert_eq!(found.entry, "seeder:althaqeel-seeder");
599            // Settings are keyed on the name, not the directory.
600            assert_eq!(found.name, "seeder");
601        }
602    }
603
604    #[test]
605    fn a_workspace_app_is_found_in_extra_apps() {
606        let (_tmp, env) = env_from("EXTRA_APPS=reports tools\n");
607
608        let found = find_declared(&env, "tools").unwrap();
609
610        assert_eq!(found.placement, Placement::Workspace);
611    }
612
613    #[test]
614    fn an_app_that_was_never_added_is_not_found() {
615        let (_tmp, env) = env_from("ROOT_APPS=seeder\n");
616
617        assert!(find_declared(&env, "ghost").is_none());
618    }
619
620    #[test]
621    fn removing_keeps_the_other_entries() {
622        assert_eq!(without("one two three", "two"), "one three");
623        assert_eq!(without("only", "only"), "");
624    }
625
626    #[test]
627    fn forgetting_drops_the_entry_and_its_settings() {
628        let (tmp, env) = env_from(
629            "ROOT_APPS=\"seeder tools\"\nSEEDER_PORT=4500\nSEEDER_CMD=\"npm run dev\"\nKEEP=yes\n",
630        );
631        let workspace = workspace_at(tmp.path());
632        let declared = find_declared(&env, "seeder").unwrap();
633
634        forget(&workspace, &declared).unwrap();
635
636        let text = std::fs::read_to_string(workspace.env_path()).unwrap();
637        assert!(text.contains("ROOT_APPS=tools"), "{text}");
638        assert!(!text.contains("SEEDER_PORT"), "port left behind: {text}");
639        assert!(!text.contains("SEEDER_CMD"), "command left behind: {text}");
640        assert!(text.contains("KEEP=yes"), "unrelated setting lost: {text}");
641    }
642
643    #[test]
644    fn add_then_remove_leaves_the_file_as_it_was() {
645        let tmp = tempfile::tempdir().unwrap();
646        let root = tmp.path();
647        std::fs::create_dir_all(root.join("seeder")).unwrap();
648        std::fs::write(
649            root.join("seeder").join("package.json"),
650            r#"{"scripts":{"dev":"node server.js"}}"#,
651        )
652        .unwrap();
653        std::fs::create_dir_all(root.join(".run")).unwrap();
654        let env_path = root.join(".run").join(".env");
655        std::fs::write(&env_path, "FRONTEND_DIR=platform\nROOT_APPS=\n").unwrap();
656        let workspace = workspace_at(root);
657
658        let env = Env::load(&env_path).unwrap();
659        let found = locate(&workspace, &env, "seeder").unwrap();
660        record(&workspace, &found).unwrap();
661
662        let env = Env::load(&env_path).unwrap();
663        let declared = find_declared(&env, "seeder").unwrap();
664        forget(&workspace, &declared).unwrap();
665
666        let text = std::fs::read_to_string(&env_path).unwrap();
667        assert!(!text.contains("SEEDER_PORT"), "{text}");
668        assert!(!text.contains("SEEDER_CMD"), "{text}");
669        assert!(text.contains("ROOT_APPS="), "{text}");
670    }
671}
672
673#[cfg(test)]
674mod completion_tests {
675    use super::*;
676
677    fn setup(env_text: &str) -> (tempfile::TempDir, Workspace) {
678        let tmp = tempfile::tempdir().unwrap();
679        let root = tmp.path();
680        std::fs::create_dir_all(root.join("seeder")).unwrap();
681        std::fs::write(
682            root.join("seeder").join("package.json"),
683            r#"{"scripts":{"dev":"node server.js"}}"#,
684        )
685        .unwrap();
686        std::fs::create_dir_all(root.join(".run")).unwrap();
687        std::fs::write(root.join(".run").join(".env"), env_text).unwrap();
688        let workspace = Workspace { root: root.to_path_buf(), run_dir: root.join(".run") };
689        (tmp, workspace)
690    }
691
692    fn add(workspace: &Workspace, name: &str) -> Vec<String> {
693        let env = Env::load(&workspace.env_path()).unwrap();
694        let found = locate(workspace, &env, name).unwrap();
695        record(workspace, &found).unwrap()
696    }
697
698    #[test]
699    fn a_listed_app_missing_its_settings_is_completed() {
700        // The list alone is not a working app, and refusing to touch it left
701        // a half-written entry half-written.
702        let (_tmp, workspace) = setup("ROOT_APPS=seeder\n");
703
704        add(&workspace, "seeder");
705
706        let text = std::fs::read_to_string(workspace.env_path()).unwrap();
707        assert!(text.contains("SEEDER_PORT="), "{text}");
708        assert!(text.contains("SEEDER_CMD="), "{text}");
709    }
710
711    #[test]
712    fn a_command_written_by_hand_is_left_alone() {
713        let (_tmp, workspace) =
714            setup("ROOT_APPS=seeder\nSEEDER_CMD=\"NO_OPEN=1 npm run dev\"\n");
715
716        let done = add(&workspace, "seeder");
717
718        let text = std::fs::read_to_string(workspace.env_path()).unwrap();
719        assert!(text.contains("SEEDER_CMD=\"NO_OPEN=1 npm run dev\""), "{text}");
720        assert!(done.iter().any(|line| line.contains("SEEDER_CMD already set")));
721    }
722
723    #[test]
724    fn settings_are_keyed_on_the_name_half_of_a_pair() {
725        // Adding by directory must not write a second set of keys under the
726        // directory's name.
727        let (_tmp, workspace) = setup("ROOT_APPS=short:seeder\n");
728
729        add(&workspace, "seeder");
730
731        let text = std::fs::read_to_string(workspace.env_path()).unwrap();
732        assert!(text.contains("SHORT_PORT="), "{text}");
733        assert!(!text.contains("SEEDER_PORT="), "keyed on the directory: {text}");
734    }
735
736    #[test]
737    fn a_fully_configured_app_is_left_untouched() {
738        let (_tmp, workspace) =
739            setup("ROOT_APPS=seeder\nSEEDER_PORT=4500\nSEEDER_CMD=\"npm run dev\"\n");
740        let before = std::fs::read_to_string(workspace.env_path()).unwrap();
741
742        let done = add(&workspace, "seeder");
743
744        assert_eq!(before, std::fs::read_to_string(workspace.env_path()).unwrap());
745        assert!(done.iter().any(|line| line.contains("nothing left to add")));
746    }
747}
748
749#[cfg(test)]
750mod naming_tests {
751    use super::*;
752
753    #[test]
754    fn a_folder_named_for_the_project_loses_the_prefix() {
755        // althaqeel-seeder in the althaqeel workspace is just "seeder": the
756        // prefix repeats what the workspace already says.
757        let name = service_name(Path::new("/w/althaqeel"), "althaqeel-seeder", Placement::Root);
758
759        assert_eq!(name, "seeder");
760    }
761
762    #[test]
763    fn a_folder_with_no_project_prefix_keeps_its_name() {
764        assert_eq!(service_name(Path::new("/w/althaqeel"), "tools", Placement::Root), "tools");
765    }
766
767    #[test]
768    fn a_folder_named_exactly_after_the_project_keeps_its_name() {
769        // Stripping would leave nothing to call it.
770        assert_eq!(
771            service_name(Path::new("/w/althaqeel"), "althaqeel", Placement::Root),
772            "althaqeel"
773        );
774    }
775
776    #[test]
777    fn an_app_inside_the_repo_keeps_its_package_name() {
778        // It is the pnpm workspace name, and --filter needs it exactly.
779        assert_eq!(
780            service_name(Path::new("/w/althaqeel"), "althaqeel-admin", Placement::Workspace),
781            "althaqeel-admin"
782        );
783    }
784
785    #[test]
786    fn the_entry_pairs_a_short_name_with_its_folder() {
787        let found = Found {
788            name: "seeder".into(),
789            dir: "althaqeel-seeder".into(),
790            placement: Placement::Root,
791            path: PathBuf::from("/w/althaqeel/althaqeel-seeder"),
792        };
793
794        assert_eq!(found.entry(), "seeder:althaqeel-seeder");
795    }
796
797    #[test]
798    fn the_entry_is_one_word_when_they_match() {
799        let found = Found {
800            name: "tools".into(),
801            dir: "tools".into(),
802            placement: Placement::Root,
803            path: PathBuf::from("/w/althaqeel/tools"),
804        };
805
806        assert_eq!(found.entry(), "tools");
807    }
808}