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,
37 pub dir: String,
39 pub placement: Placement,
40 pub path: PathBuf,
41}
42
43impl Found {
44 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
55pub 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
76pub 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
89pub 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
129pub 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
150pub 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
169pub 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
195pub 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
202pub 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 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(¤t, &found.entry())));
228 } else {
229 notes.push(format!("{list_key} already lists {}", found.name));
230 }
231
232 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 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#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct Declared {
287 pub entry: String,
288 pub name: String,
289 pub placement: Placement,
290}
291
292pub 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
320pub 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 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 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 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 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 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 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 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 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}