1use core::fmt;
7
8use std::path::Path;
9
10use std::collections::BTreeSet;
11
12use globset::Glob;
13
14use crate::config::{
15 AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
16};
17use crate::secrets;
18use crate::values::UpDuration;
19
20const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
30
31const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
38
39const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedApp {
51 config: AppConfig,
52}
53
54impl ResolvedApp {
55 #[must_use]
57 pub fn config(&self) -> &AppConfig {
58 &self.config
59 }
60
61 #[must_use]
63 pub fn into_config(self) -> AppConfig {
64 self.config
65 }
66}
67
68fn expand_tilde(
81 value: &str,
82 home: Option<&Path>,
83 name: &str,
84 field: &'static str,
85) -> Result<String, NormalizeError> {
86 expand_home_tilde(value, home).map_err(|err| match err {
87 TildeError::OtherUser => NormalizeError::TildeUser {
88 name: name.to_string(),
89 field,
90 value: value.to_string(),
91 },
92 TildeError::NoHome => NormalizeError::NoHomeForTilde {
93 name: name.to_string(),
94 field,
95 },
96 })
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TildeError {
103 OtherUser,
108 NoHome,
110}
111
112impl fmt::Display for TildeError {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::OtherUser => write!(
116 f,
117 "shep expands only `~/` (your own home); another user's home needs a \
118 passwd lookup whose answer depends on who the process runs as"
119 ),
120 Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
121 }
122 }
123}
124
125impl core::error::Error for TildeError {}
126
127pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
138 let Some(rest) = value.strip_prefix('~') else {
139 return Ok(value.to_string());
140 };
141 if !(rest.is_empty() || rest.starts_with('/')) {
143 return Err(TildeError::OtherUser);
144 }
145 let Some(home) = home else {
146 return Err(TildeError::NoHome);
147 };
148 let joined = home.join(rest.trim_start_matches('/'));
151 Ok(joined.to_string_lossy().into_owned())
152}
153
154#[cfg(test)]
162const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
163
164fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
169 let name = app.name.clone();
170 app.script = expand_tilde(&app.script, home, &name, "script")?;
171 for (field, slot) in [
172 ("cwd", &mut app.cwd),
173 ("out_file", &mut app.out_file),
174 ("err_file", &mut app.err_file),
175 ] {
176 if let Some(value) = slot {
177 *slot = Some(expand_tilde(value, home, &name, field)?);
178 }
179 }
180 Ok(())
181}
182
183pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
213 normalize_with_home(app, std::env::home_dir().as_deref())
214}
215
216pub fn normalize_with_home(
227 mut app: AppConfig,
228 home: Option<&Path>,
229) -> Result<ResolvedApp, NormalizeError> {
230 if app.name.is_empty() {
231 return Err(NormalizeError::MissingName);
232 }
233 if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
234 return Err(NormalizeError::InvalidName(app.name));
235 }
236 if let Some(environment) = &app.environment
237 && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
238 {
239 return Err(NormalizeError::InvalidEnvironment {
240 name: app.name.clone(),
241 value: environment.clone(),
242 });
243 }
244 for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
245 if app.env.contains_key(var) {
246 return Err(NormalizeError::ReservedEnvVar {
247 name: app.name.clone(),
248 var,
249 });
250 }
251 }
252 if let Some(var) = app.increment_var.take() {
253 return Err(NormalizeError::IncrementVarRemoved {
254 name: app.name.clone(),
255 var,
256 });
257 }
258 for (key, value) in &app.env {
259 validate_template(&app.name, &format!("env.{key}"), value)?;
260 }
261 for (index, value) in app.args.iter().enumerate() {
262 validate_template(&app.name, &format!("args[{index}]"), value)?;
263 }
264 for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
265 if let Some(value) = value {
266 validate_template(&app.name, field, value)?;
267 if crate::config::template::holds_secret(value) {
268 return Err(NormalizeError::SecretInLogPath {
269 name: app.name.clone(),
270 field,
271 });
272 }
273 }
274 }
275 if app.script.is_empty() {
276 return Err(NormalizeError::MissingScript);
277 }
278 expand_paths(&mut app, home)?;
282 if app.instances == 0 {
283 return Err(NormalizeError::ZeroInstances);
284 }
285 if app.instances > 1 && !app.merge_logs {
288 for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
289 if let Some(path) = path
294 && crate::config::template::render_positional(path, &app.name, 0)
295 == crate::config::template::render_positional(path, &app.name, 1)
296 {
297 return Err(NormalizeError::SharedLogPath {
298 name: app.name.clone(),
299 field,
300 });
301 }
302 }
303 }
304 if let Some(pattern) = &app.cron_restart {
305 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
306 CronParseError::Pattern { pattern, reason } => {
307 NormalizeError::InvalidCron { pattern, reason }
308 }
309 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
310 })?;
311 } else if let Some(tz_name) = &app.cron_timezone {
312 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
315 NormalizeError::InvalidTimezone {
316 name: tz_name.clone(),
317 }
318 })?;
319 }
320 validate_probe(
321 app.readiness_probe.as_ref(),
322 "readiness_probe",
323 MIN_READINESS_INTERVAL,
324 )?;
325 validate_probe(
326 app.liveness_probe.as_ref(),
327 "liveness_probe",
328 MIN_LIVENESS_INTERVAL,
329 )?;
330 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
331 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
336 }
337 if let Some(name) = &app.kill_signal
338 && KillSignal::parse(name).is_none()
339 {
340 return Err(NormalizeError::InvalidKillSignal {
344 name: app.name,
345 value: name.clone(),
346 });
347 }
348 if app.action_timeout > MAX_ACTION_TIMEOUT {
349 return Err(NormalizeError::ActionTimeoutTooLong {
353 name: app.name,
354 value: app.action_timeout,
355 max: MAX_ACTION_TIMEOUT,
356 });
357 }
358 if app.watch && app.cwd.is_none() {
359 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
364 }
365 if app.watch_delay == Some(UpDuration::from_millis(0)) {
366 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
370 }
371 validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
375 validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
376 let mut seen = BTreeSet::new();
377 let mut deduped = Vec::with_capacity(app.depends_on.len());
378 for target in &app.depends_on {
379 if target == &app.name {
380 return Err(NormalizeError::SelfDependency(app.name.clone()));
381 }
382 if target.contains(':') {
383 return Err(NormalizeError::InstanceDependency {
384 sheep: app.name.clone(),
385 target: target.clone(),
386 });
387 }
388 if seen.insert(target.clone()) {
389 deduped.push(target.clone());
390 }
391 }
392 app.depends_on = deduped;
393 Ok(ResolvedApp { config: app })
394}
395
396fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
406 crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
407 name: name.to_string(),
408 field: field.to_string(),
409 reason: reason.to_string(),
410 })
411}
412
413fn validate_watch_globs(
420 name: &str,
421 field: &'static str,
422 patterns: &[String],
423) -> Result<(), NormalizeError> {
424 for pattern in patterns {
425 Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
426 name: name.to_string(),
427 field,
428 pattern: pattern.clone(),
429 reason: err.to_string(),
430 })?;
431 }
432 Ok(())
433}
434
435fn validate_probe(
442 probe: Option<&ProbeConfig>,
443 name: &'static str,
444 min_interval: UpDuration,
445) -> Result<(), NormalizeError> {
446 let Some(probe) = probe else {
447 return Ok(());
448 };
449 ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
450 probe: name,
451 reason: reason.to_string(),
452 })?;
453 if probe.failure_threshold == 0 {
454 return Err(NormalizeError::ZeroFailureThreshold { probe: name });
457 }
458 if probe.interval < min_interval {
459 return Err(NormalizeError::IntervalBelowMinimum {
464 probe: name,
465 value: probe.interval,
466 min: min_interval,
467 });
468 }
469 Ok(())
470}
471
472pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
480 let mut seen = BTreeSet::new();
481 let resolved: Vec<ResolvedApp> = apps
482 .into_iter()
483 .map(|app| {
484 if !seen.insert(app.name.clone()) {
485 return Err(NormalizeError::DuplicateName(app.name));
486 }
487 normalize(app)
488 })
489 .collect::<Result<_, _>>()?;
490 let nodes: Vec<crate::config::graph::BootNode> = resolved
493 .iter()
494 .map(|app| crate::config::graph::BootNode {
495 name: app.config().name.clone(),
496 depends_on: app.config().depends_on.clone(),
497 kind: crate::config::graph::NodeKind::Sheep,
498 })
499 .collect();
500 if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
501 return Err(NormalizeError::DependencyCycle(cycle));
502 }
503 Ok(resolved)
504}
505
506#[non_exhaustive]
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub enum NormalizeError {
513 MissingName,
515 InvalidName(String),
521 InvalidEnvironment {
526 name: String,
528 value: String,
530 },
531 ReservedEnvVar {
534 name: String,
536 var: &'static str,
538 },
539 IncrementVarRemoved {
543 name: String,
545 var: String,
547 },
548 MissingScript,
550 ZeroInstances,
552 InvalidCron {
555 pattern: String,
557 reason: String,
559 },
560 InvalidTimezone {
562 name: String,
564 },
565 DuplicateName(String),
567 DependencyCycle(Vec<String>),
571 InvalidProbe {
574 probe: &'static str,
577 reason: String,
579 },
580 ZeroFailureThreshold {
582 probe: &'static str,
585 },
586 IntervalBelowMinimum {
591 probe: &'static str,
594 value: UpDuration,
596 min: UpDuration,
598 },
599 ZeroMaxMemory {
603 name: String,
605 },
606 ActionTimeoutTooLong {
611 name: String,
613 value: UpDuration,
615 max: UpDuration,
617 },
618 InvalidKillSignal {
621 name: String,
623 value: String,
625 },
626 WatchWithoutCwd {
629 name: String,
631 },
632 TildeUser {
638 name: String,
640 field: &'static str,
642 value: String,
644 },
645 NoHomeForTilde {
647 name: String,
649 field: &'static str,
651 },
652 ZeroWatchDelay {
655 name: String,
657 },
658 InvalidWatchGlob {
661 name: String,
663 field: &'static str,
666 pattern: String,
668 reason: String,
670 },
671 BadTemplate {
675 name: String,
677 field: String,
679 reason: String,
682 },
683 SecretInLogPath {
691 name: String,
693 field: &'static str,
695 },
696 SharedLogPath {
702 name: String,
704 field: &'static str,
706 },
707 SelfDependency(String),
711 InstanceDependency {
714 sheep: String,
716 target: String,
718 },
719}
720
721impl fmt::Display for NormalizeError {
722 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
723 match self {
724 Self::MissingName => f.write_str("app config is missing a name"),
725 Self::InvalidName(n) => {
726 write!(
727 f,
728 "sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
729 )
730 }
731 Self::InvalidEnvironment { name, value } => write!(
732 f,
733 "sheep `{name}` has environment = `{value}`: must be 1-128 bytes of \
734 `[A-Za-z0-9._-]` not starting with `.`, and not `{}` (the secrets \
735 store's every-environment slot)",
736 secrets::ALL_ENVIRONMENTS
737 ),
738 Self::ReservedEnvVar { name, var } => write!(
739 f,
740 "sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
741 ),
742 Self::IncrementVarRemoved { name, var } => write!(
743 f,
744 "sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
745 ),
746 Self::MissingScript => f.write_str("app config is missing a script"),
747 Self::ZeroInstances => f.write_str("instances must be at least 1"),
748 Self::InvalidCron { pattern, reason } => {
749 write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
750 }
751 Self::InvalidTimezone { name } => {
752 write!(f, "`{name}` is not a recognized IANA timezone")
753 }
754 Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
755 Self::DependencyCycle(cycle) => write!(
756 f,
757 "dependency cycle: {}",
758 crate::config::graph::render_cycle(cycle)
759 ),
760 Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
761 Self::ZeroFailureThreshold { probe } => {
762 write!(f, "{probe}.failure_threshold must be at least 1")
763 }
764 Self::IntervalBelowMinimum { probe, value, min } => {
765 write!(f, "{probe}.interval is `{value}`: must be at least {min}")
766 }
767 Self::ZeroMaxMemory { name } => {
768 write!(
769 f,
770 "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
771 )
772 }
773 Self::ActionTimeoutTooLong { name, value, max } => {
774 write!(
775 f,
776 "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
777 the longest wait any caller's deadline could ever cover"
778 )
779 }
780 Self::InvalidKillSignal { name, value } => {
781 write!(
782 f,
783 "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
784 KillSignal::ACCEPTED.join(", ")
785 )
786 }
787 Self::TildeUser { name, field, value } => write!(
788 f,
789 "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
790 Another user's home needs a passwd lookup whose answer depends on who the \
791 daemon runs as, so write the path out in full instead."
792 ),
793 Self::NoHomeForTilde { name, field } => write!(
794 f,
795 "`{name}`: {field} begins with `~/` but no home directory could be found. \
796 Set $HOME, or write the path out in full."
797 ),
798 Self::WatchWithoutCwd { name } => {
799 write!(f, "sheep `{name}` has watch = true but no cwd to watch")
800 }
801 Self::ZeroWatchDelay { name } => {
802 write!(
803 f,
804 "sheep `{name}` has watch_delay = 0: must be greater than 0"
805 )
806 }
807 Self::InvalidWatchGlob {
808 name,
809 field,
810 pattern,
811 reason,
812 } => write!(
813 f,
814 "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
815 ),
816 Self::BadTemplate {
817 name,
818 field,
819 reason,
820 } => write!(f, "sheep `{name}`, {field}: {reason}"),
821 Self::SecretInLogPath { name, field } => write!(
822 f,
823 "sheep `{name}` puts a `{{{{secret:...}}}}` in `{field}`: a log path may not hold a secret, since it becomes a filename on disk and is reported by `shep flock` and `shep describe`"
824 ),
825 Self::SharedLogPath { name, field } => write!(
826 f,
827 "sheep `{name}` runs several instances and sets `{field}` to one path: put `{{{{instance}}}}` in it, or set `merge_logs = true` to share it on purpose"
828 ),
829 Self::SelfDependency(n) => {
830 write!(f, "`{n}` names itself in depends_on")
831 }
832 Self::InstanceDependency { sheep, target } => {
833 let app = target.split(':').next().unwrap_or(target);
834 write!(
835 f,
836 "`{sheep}` depends on `{target}`, which names one instance. \
837 Depend on `{app}` instead: a dependency waits for every \
838 instance of an app"
839 )
840 }
841 }
842 }
843}
844
845impl core::error::Error for NormalizeError {}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850
851 #[test]
855 fn every_path_field_expands_a_leading_tilde() {
856 let home = Path::new("/home/ada");
857 let mut app = AppConfig::minimal("web", "~/app/server.js");
858 app.cwd = Some("~/app".to_string());
859 app.out_file = Some("~/logs/out.log".to_string());
860 app.err_file = Some("~/logs/err.log".to_string());
861
862 let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
863 let c = resolved.config();
864 let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
868 assert_eq!(c.script, expect("app/server.js"));
869 assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
870 assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
871 assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
872 }
873
874 #[test]
878 fn the_path_field_list_matches_what_expand_paths_walks() {
879 let home = Path::new("/home/ada");
880 let mut app = AppConfig::minimal("web", "~/s");
881 app.cwd = Some("~/c".to_string());
882 app.out_file = Some("~/o".to_string());
883 app.err_file = Some("~/e".to_string());
884
885 let resolved = normalize_with_home(app, Some(home)).expect("expands");
886 let c = resolved.config();
887 let expanded = [
888 ("script", Some(c.script.as_str())),
889 ("cwd", c.cwd.as_deref()),
890 ("out_file", c.out_file.as_deref()),
891 ("err_file", c.err_file.as_deref()),
892 ];
893 assert_eq!(
894 expanded.len(),
895 PATH_FIELDS.len(),
896 "PATH_FIELDS and this test must name the same set"
897 );
898 for (field, value) in expanded {
899 assert!(
900 PATH_FIELDS.contains(&field),
901 "`{field}` is not in PATH_FIELDS"
902 );
903 assert!(
904 value.is_some_and(|v| v.starts_with("/home/ada")),
905 "`{field}` was not expanded: {value:?}"
906 );
907 }
908 }
909
910 #[test]
913 fn a_path_without_a_tilde_is_left_exactly_as_written() {
914 let app = AppConfig::minimal("web", "./server.js");
915 let resolved =
916 normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
917 assert_eq!(resolved.config().script, "./server.js");
918 }
919
920 #[test]
923 fn another_users_home_is_refused_rather_than_resolved() {
924 let app = AppConfig::minimal("web", "~deploy/app/server.js");
925 let err = normalize_with_home(app, Some(Path::new("/home/ada")))
926 .expect_err("~user/ must not resolve");
927 assert!(
928 matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
929 "the refusal names the field: {err:?}"
930 );
931 let rendered = err.to_string();
932 assert!(
933 rendered.contains("~/"),
934 "and says what IS supported: {rendered}"
935 );
936 assert!(
937 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
938 "no em or en dash in copy a user reads: {rendered}"
939 );
940 }
941
942 #[test]
945 fn a_dollar_variable_is_not_expanded() {
946 let app = AppConfig::minimal("web", "$HOME/server.js");
947 let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
948 assert_eq!(resolved.config().script, "$HOME/server.js");
949 }
950
951 #[test]
954 fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
955 let app = AppConfig::minimal("web", "~/server.js");
956 let err = normalize_with_home(app, None).expect_err("nothing to expand against");
957 assert!(
958 matches!(err, NormalizeError::NoHomeForTilde { .. }),
959 "{err:?}"
960 );
961 }
962
963 #[test]
967 fn expand_home_tilde_covers_its_four_documented_cases() {
968 let home = Path::new("/home/ada");
969 assert_eq!(
970 expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
971 home.join("bin/dog").to_string_lossy()
972 );
973 assert_eq!(
974 expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
975 "/opt/bin/dog",
976 "a value with no leading ~ is returned unchanged"
977 );
978 assert_eq!(
979 expand_home_tilde("~/bin/dog", None).unwrap_err(),
980 TildeError::NoHome
981 );
982 assert_eq!(
983 expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
984 TildeError::OtherUser
985 );
986 }
987
988 #[test]
992 fn reuse_port_loads_now_that_reload_reads_it() {
993 let mut app = AppConfig::minimal("web", "./server");
994 app.reuse_port = true;
995
996 let resolved = normalize(app).expect("reuse_port is implemented");
997 assert!(resolved.config().reuse_port);
998 }
999
1000 #[test]
1003 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
1004 let resolved = normalize(AppConfig::minimal("web", "./server"))
1005 .expect("the common case must be untouched");
1006 assert!(!resolved.config().reuse_port);
1007 }
1008 use crate::config::AppConfig;
1009
1010 #[test]
1011 fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
1012 let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
1013 assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
1014
1015 let rendered = err.to_string();
1016 assert!(rendered.contains(':'), "says which character: {rendered}");
1017 assert!(
1019 rendered.contains("`-`"),
1020 "suggests the stand-in: {rendered}"
1021 );
1022 assert!(
1023 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1024 "no em or en dash in copy a user reads: {rendered}"
1025 );
1026
1027 assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
1028 }
1029
1030 #[test]
1031 fn increment_var_is_refused_and_says_what_replaced_it() {
1032 let mut app = AppConfig::minimal("web", "./srv");
1033 app.increment_var = Some("WORKER_ID".to_string());
1034 let err = normalize(app).unwrap_err();
1035 let rendered = err.to_string();
1036 assert!(rendered.contains("increment_var"), "{rendered}");
1037 assert!(
1038 rendered.contains("WORKER_ID"),
1039 "keeps their name: {rendered}"
1040 );
1041 assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
1042 assert!(
1043 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1044 "no em or en dash in copy a user reads: {rendered}"
1045 );
1046 }
1047
1048 #[test]
1049 fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
1050 for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
1051 let mut app = AppConfig::minimal("web", "./srv");
1052 app.env.insert(var.to_string(), "mine".to_string());
1053 let err = normalize(app).unwrap_err();
1054 let rendered = err.to_string();
1055 assert!(rendered.contains(var), "names the variable: {rendered}");
1056 assert!(
1057 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1058 "no em or en dash in copy a user reads: {rendered}"
1059 );
1060 }
1061 }
1062
1063 #[test]
1064 fn valid_minimal_config_normalizes() {
1065 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
1066 assert_eq!(resolved.config().name, "web");
1067 }
1068
1069 #[test]
1070 fn names_that_reach_the_filesystem_are_rejected() {
1071 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
1074 assert_eq!(
1075 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
1076 NormalizeError::InvalidName(bad.to_string())
1077 );
1078 }
1079 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
1080 }
1081
1082 #[test]
1083 fn missing_name_and_script_are_distinct_errors() {
1084 assert_eq!(
1085 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
1086 NormalizeError::MissingName
1087 );
1088 assert_eq!(
1089 normalize(AppConfig::minimal("web", "")).unwrap_err(),
1090 NormalizeError::MissingScript
1091 );
1092 }
1093
1094 #[test]
1095 fn zero_instances_rejected() {
1096 let mut app = AppConfig::minimal("web", "./srv");
1097 app.instances = 0;
1098 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
1099 }
1100
1101 #[test]
1102 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
1103 let mut app = AppConfig::minimal("web", "./srv");
1105 app.cron_restart = Some("not a cron".to_string());
1106 match normalize(app).unwrap_err() {
1107 NormalizeError::InvalidCron { pattern, reason } => {
1108 assert_eq!(pattern, "not a cron");
1109 assert!(!reason.is_empty());
1110 }
1111 other => panic!("expected InvalidCron, got {other:?}"),
1112 }
1113 }
1114
1115 #[test]
1116 fn five_tokens_of_garbage_cron_pattern_rejected() {
1117 let mut app = AppConfig::minimal("web", "./srv");
1121 app.cron_restart = Some("99 99 99 99 99".to_string());
1122 match normalize(app).unwrap_err() {
1123 NormalizeError::InvalidCron { pattern, .. } => {
1124 assert_eq!(pattern, "99 99 99 99 99");
1125 }
1126 other => panic!("expected InvalidCron, got {other:?}"),
1127 }
1128 }
1129
1130 #[test]
1131 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
1132 let mut app = AppConfig::minimal("web", "./srv");
1137 app.cron_restart = Some("0 3 * * *".to_string());
1138 app.cron_timezone = Some("Mars/Olympus".to_string());
1139 match normalize(app).unwrap_err() {
1140 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1141 other => panic!("expected InvalidTimezone, got {other:?}"),
1142 }
1143 }
1144
1145 #[test]
1146 fn cron_timezone_validated_even_without_cron_restart() {
1147 let mut app = AppConfig::minimal("web", "./srv");
1151 app.cron_timezone = Some("Mars/Olympus".to_string());
1152 match normalize(app).unwrap_err() {
1153 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1154 other => panic!("expected InvalidTimezone, got {other:?}"),
1155 }
1156 }
1157
1158 #[test]
1159 fn duplicate_names_rejected_across_a_flock() {
1160 let apps = vec![
1161 AppConfig::minimal("web", "./a"),
1162 AppConfig::minimal("web", "./b"),
1163 ];
1164 assert_eq!(
1165 normalize_all(apps).unwrap_err(),
1166 NormalizeError::DuplicateName("web".to_string())
1167 );
1168 }
1169
1170 #[test]
1171 fn a_cycle_inside_one_document_is_refused_and_named() {
1172 let mut a = AppConfig::minimal("a", "./a");
1174 a.depends_on = vec!["b".to_string()];
1175 let mut b = AppConfig::minimal("b", "./b");
1176 b.depends_on = vec!["a".to_string()];
1177 match normalize_all(vec![a, b]) {
1178 Err(NormalizeError::DependencyCycle(cycle)) => {
1179 let rendered = NormalizeError::DependencyCycle(cycle).to_string();
1180 assert!(rendered.contains("a"), "{rendered}");
1181 assert!(rendered.contains("b"), "{rendered}");
1182 assert!(
1183 rendered.contains(" -> "),
1184 "the path must be shown: {rendered}"
1185 );
1186 }
1187 other => panic!("expected DependencyCycle, got {other:?}"),
1188 }
1189 }
1190
1191 #[test]
1192 fn a_dependency_on_an_app_outside_the_document_is_accepted() {
1193 let mut api = AppConfig::minimal("api", "./api");
1196 api.depends_on = vec!["db-in-another-repo".to_string()];
1197 normalize_all(vec![api]).expect("an unresolved name is not a document error");
1198 }
1199
1200 fn probe_config(target: &str) -> crate::config::ProbeConfig {
1201 crate::config::ProbeConfig {
1202 kind: crate::config::ProbeKind::Http,
1203 target: target.to_string(),
1204 interval: crate::values::UpDuration::from_millis(10_000),
1205 timeout: crate::values::UpDuration::from_millis(5_000),
1206 failure_threshold: 3,
1207 }
1208 }
1209
1210 #[test]
1211 fn malformed_readiness_probe_target_rejected_naming_the_field() {
1212 let mut app = AppConfig::minimal("web", "./srv");
1215 app.readiness_probe = Some(probe_config("not-a-url"));
1216 match normalize(app).unwrap_err() {
1217 NormalizeError::InvalidProbe { probe, reason } => {
1218 assert_eq!(probe, "readiness_probe");
1219 assert!(!reason.is_empty());
1220 }
1221 other => panic!("expected InvalidProbe, got {other:?}"),
1222 }
1223 }
1224
1225 #[test]
1226 fn malformed_liveness_probe_target_rejected_naming_the_field() {
1227 let mut app = AppConfig::minimal("web", "./srv");
1230 app.liveness_probe = Some(probe_config("not-a-url"));
1231 match normalize(app).unwrap_err() {
1232 NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1233 other => panic!("expected InvalidProbe, got {other:?}"),
1234 }
1235 }
1236
1237 #[test]
1238 fn valid_probe_targets_accepted() {
1239 let mut app = AppConfig::minimal("web", "./srv");
1241 app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1242 assert!(normalize(app).is_ok());
1243 }
1244
1245 #[test]
1246 fn zero_failure_threshold_rejected() {
1247 let mut app = AppConfig::minimal("web", "./srv");
1250 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1251 probe.failure_threshold = 0;
1252 app.readiness_probe = Some(probe);
1253 let err = normalize(app).unwrap_err();
1254 assert_eq!(
1255 err,
1256 NormalizeError::ZeroFailureThreshold {
1257 probe: "readiness_probe"
1258 }
1259 );
1260 assert!(err.to_string().contains("at least 1"), "{err}");
1263 }
1264
1265 #[test]
1266 fn zero_interval_rejected() {
1267 let mut app = AppConfig::minimal("web", "./srv");
1271 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1272 probe.interval = UpDuration::from_millis(0);
1273 app.readiness_probe = Some(probe);
1274 let err = normalize(app).unwrap_err();
1275 assert_eq!(
1276 err,
1277 NormalizeError::IntervalBelowMinimum {
1278 probe: "readiness_probe",
1279 value: UpDuration::from_millis(0),
1280 min: MIN_READINESS_INTERVAL,
1281 }
1282 );
1283 assert!(err.to_string().contains("must be at least"), "{err}");
1286 }
1287
1288 #[test]
1289 fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1290 let mut app = AppConfig::minimal("web", "./srv");
1295 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1296 probe.interval = UpDuration::from_millis(500);
1297 app.liveness_probe = Some(probe);
1298 let err = normalize(app).unwrap_err();
1299 assert_eq!(
1300 err,
1301 NormalizeError::IntervalBelowMinimum {
1302 probe: "liveness_probe",
1303 value: UpDuration::from_millis(500),
1304 min: MIN_LIVENESS_INTERVAL,
1305 }
1306 );
1307 assert!(err.to_string().contains("500"), "{err}");
1308 }
1309
1310 #[test]
1311 fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1312 let mut app = AppConfig::minimal("web", "./srv");
1316 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1317 probe.interval = MIN_LIVENESS_INTERVAL;
1318 app.liveness_probe = Some(probe);
1319 assert!(normalize(app).is_ok());
1320 }
1321
1322 #[test]
1323 fn a_sub_second_readiness_interval_is_accepted() {
1324 let mut app = AppConfig::minimal("web", "./srv");
1329 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1330 probe.interval = UpDuration::from_millis(50);
1331 app.readiness_probe = Some(probe);
1332 assert!(normalize(app).is_ok());
1333 }
1334
1335 #[test]
1336 fn zero_max_memory_rejected() {
1337 let mut app = AppConfig::minimal("web", "./srv");
1342 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1343 let err = normalize(app).unwrap_err();
1344 assert_eq!(
1345 err,
1346 NormalizeError::ZeroMaxMemory {
1347 name: "web".to_string()
1348 }
1349 );
1350 assert!(err.to_string().contains("max_memory"), "{err}");
1353 }
1354
1355 #[test]
1356 fn a_nonzero_max_memory_is_accepted() {
1357 let mut app = AppConfig::minimal("web", "./srv");
1361 app.max_memory = Some("512M".parse().unwrap());
1362 assert!(normalize(app).is_ok());
1363 }
1364
1365 #[test]
1369 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1370 let mut app = AppConfig::minimal("web", "./srv");
1371 app.kill_signal = Some("SIGUSR1".to_string());
1372
1373 let err = normalize(app).unwrap_err();
1374
1375 assert_eq!(
1376 err,
1377 NormalizeError::InvalidKillSignal {
1378 name: "web".to_string(),
1379 value: "SIGUSR1".to_string(),
1380 }
1381 );
1382 let rendered = err.to_string();
1385 assert!(rendered.contains("SIGUSR1"), "{rendered}");
1386 assert!(rendered.contains("SIGTERM"), "{rendered}");
1387 assert!(rendered.contains("SIGUSR2"), "{rendered}");
1388 }
1389
1390 #[test]
1394 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1395 for name in [
1396 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1397 "USR2", "sigusr2",
1398 ] {
1399 let mut app = AppConfig::minimal("web", "./srv");
1400 app.kill_signal = Some(name.to_string());
1401 assert!(
1402 normalize(app).is_ok(),
1403 "`{name}` was accepted before this task and must still be"
1404 );
1405 }
1406 }
1407
1408 #[test]
1412 fn an_unset_kill_signal_is_not_a_config_error() {
1413 let app = AppConfig::minimal("web", "./srv");
1414 assert!(app.kill_signal.is_none());
1415 assert!(normalize(app).is_ok());
1416 }
1417
1418 #[test]
1419 fn a_sheep_cannot_claim_the_all_environment() {
1420 let mut app = AppConfig::minimal("web", "./srv");
1423 app.environment = Some("all".to_string());
1424 let err = normalize(app).unwrap_err();
1425 assert!(err.to_string().contains("all"), "{err}");
1426 }
1427
1428 #[test]
1429 fn an_environment_outside_the_grammar_is_refused() {
1430 for bad in ["", "has space", "has/slash"] {
1431 let mut app = AppConfig::minimal("web", "./srv");
1432 app.environment = Some(bad.to_string());
1433 assert!(normalize(app).is_err(), "{bad:?} must be refused");
1434 }
1435 }
1436
1437 #[test]
1438 fn action_timeout_past_the_ceiling_is_rejected() {
1439 let mut app = AppConfig::minimal("web", "./srv");
1444 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1445 let err = normalize(app).unwrap_err();
1446 assert_eq!(
1447 err,
1448 NormalizeError::ActionTimeoutTooLong {
1449 name: "web".to_string(),
1450 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1451 max: MAX_ACTION_TIMEOUT,
1452 }
1453 );
1454 assert!(err.to_string().contains("action_timeout"), "{err}");
1457 }
1458
1459 #[test]
1460 fn action_timeout_at_the_ceiling_is_accepted() {
1461 let mut app = AppConfig::minimal("web", "./srv");
1465 app.action_timeout = MAX_ACTION_TIMEOUT;
1466 assert!(normalize(app).is_ok());
1467 }
1468
1469 #[test]
1470 fn the_default_action_timeout_is_accepted() {
1471 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1475 }
1476
1477 #[test]
1478 fn zero_watch_delay_rejected() {
1479 let mut app = AppConfig::minimal("web", "./srv");
1484 app.watch = true;
1485 app.cwd = Some("/srv/web".to_string());
1486 app.watch_delay = Some(UpDuration::from_millis(0));
1487 let err = normalize(app).unwrap_err();
1488 assert_eq!(
1489 err,
1490 NormalizeError::ZeroWatchDelay {
1491 name: "web".to_string()
1492 }
1493 );
1494 assert!(err.to_string().contains("watch_delay"), "{err}");
1497 }
1498
1499 #[test]
1500 fn a_zero_watch_delay_is_rejected_with_watch_off() {
1501 let mut app = AppConfig::minimal("web", "./srv");
1506 app.watch_delay = Some(UpDuration::from_millis(0));
1507 assert!(matches!(
1508 normalize(app).unwrap_err(),
1509 NormalizeError::ZeroWatchDelay { .. }
1510 ));
1511 }
1512
1513 #[test]
1514 fn a_nonzero_watch_delay_is_accepted() {
1515 let mut app = AppConfig::minimal("web", "./srv");
1519 app.watch = true;
1520 app.cwd = Some("/srv/web".to_string());
1521 app.watch_delay = Some(UpDuration::from_millis(1));
1522 assert!(normalize(app).is_ok());
1523 }
1524
1525 #[test]
1526 fn default_failure_threshold_from_toml_accepted() {
1527 let src = r#"
1532name = "web"
1533script = "./srv"
1534
1535[readiness_probe]
1536kind = "http"
1537target = "http://127.0.0.1:8080/healthz"
1538"#;
1539 let app: AppConfig = toml::from_str(src).unwrap();
1540 assert!(normalize(app).is_ok());
1541 }
1542
1543 #[test]
1544 fn watch_true_without_cwd_rejected_naming_the_app() {
1545 let mut app = AppConfig::minimal("web", "./srv");
1549 app.watch = true;
1550 let err = normalize(app).unwrap_err();
1551 assert_eq!(
1552 err,
1553 NormalizeError::WatchWithoutCwd {
1554 name: "web".to_string()
1555 }
1556 );
1557 assert!(err.to_string().contains("no cwd to watch"), "{err}");
1560 }
1561
1562 #[test]
1563 fn watch_true_with_cwd_accepted() {
1564 let mut app = AppConfig::minimal("web", "./srv");
1567 app.watch = true;
1568 app.cwd = Some("/srv/web".to_string());
1569 assert!(normalize(app).is_ok());
1570 }
1571
1572 #[test]
1573 fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1574 let mut app = AppConfig::minimal("web", "./srv");
1579 app.watch = true;
1580 app.cwd = Some("/srv/web".to_string());
1581 app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1582 let err = normalize(app).unwrap_err();
1583 assert_eq!(
1584 err,
1585 NormalizeError::InvalidWatchGlob {
1586 name: "web".to_string(),
1587 field: "watch_options",
1588 pattern: "[".to_string(),
1589 reason: Glob::new("[").unwrap_err().to_string(),
1590 }
1591 );
1592 let rendered = err.to_string();
1595 for expected in ["web", "watch_options", "`[`"] {
1596 assert!(
1597 rendered.contains(expected),
1598 "{expected} missing: {rendered}"
1599 );
1600 }
1601 }
1602
1603 #[test]
1604 fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1605 let mut app = AppConfig::minimal("web", "./srv");
1608 app.watch = true;
1609 app.cwd = Some("/srv/web".to_string());
1610 app.ignore_watch = vec!["[".to_string()];
1611 match normalize(app).unwrap_err() {
1612 NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1613 assert_eq!(field, "ignore_watch");
1614 assert_eq!(pattern, "[");
1615 }
1616 other => panic!("expected InvalidWatchGlob, got {other:?}"),
1617 }
1618 }
1619
1620 #[test]
1621 fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1622 let mut app = AppConfig::minimal("web", "./srv");
1627 app.watch_options = vec!["[".to_string()];
1628 assert!(matches!(
1629 normalize(app).unwrap_err(),
1630 NormalizeError::InvalidWatchGlob { .. }
1631 ));
1632 }
1633
1634 #[test]
1635 fn well_formed_watch_globs_are_accepted() {
1636 let mut app = AppConfig::minimal("web", "./srv");
1641 app.watch = true;
1642 app.cwd = Some("/srv/web".to_string());
1643 app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1644 app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1645 assert!(normalize(app).is_ok());
1646 }
1647
1648 #[test]
1649 fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
1650 let mut app = AppConfig::minimal("web", "./srv");
1651 app.env
1652 .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
1653 let err = normalize(app).unwrap_err();
1654 let rendered = err.to_string();
1655 assert!(rendered.contains("instnace"), "names the typo: {rendered}");
1656 assert!(rendered.contains("WORKER"), "and the field: {rendered}");
1657 }
1658
1659 #[test]
1660 fn a_typo_in_an_arg_template_is_refused_too() {
1661 let mut app = AppConfig::minimal("web", "./srv");
1662 app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
1663 let err = normalize(app).unwrap_err();
1664 assert!(err.to_string().contains("slot"), "{err}");
1665 }
1666
1667 #[test]
1668 fn an_explicit_log_path_shared_by_every_instance_is_refused() {
1669 let mut app = AppConfig::minimal("web", "./srv");
1670 app.instances = 3;
1671 app.out_file = Some("/var/log/web.log".to_string());
1672 let err = normalize(app).unwrap_err();
1673 let rendered = err.to_string();
1674 assert!(rendered.contains("out_file"), "names the field: {rendered}");
1675 assert!(
1676 rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
1677 "and both ways out: {rendered}"
1678 );
1679 assert!(
1680 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1681 "no em or en dash in copy a user reads: {rendered}"
1682 );
1683 }
1684
1685 #[test]
1686 fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
1687 let mut templated = AppConfig::minimal("web", "./srv");
1689 templated.instances = 3;
1690 templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
1691 assert!(normalize(templated).is_ok());
1692
1693 let mut merged = AppConfig::minimal("web", "./srv");
1695 merged.instances = 3;
1696 merged.out_file = Some("/var/log/web.log".to_string());
1697 merged.merge_logs = true;
1698 assert!(normalize(merged).is_ok());
1699
1700 let mut single = AppConfig::minimal("web", "./srv");
1702 single.out_file = Some("/var/log/web.log".to_string());
1703 assert!(normalize(single).is_ok());
1704 }
1705
1706 #[test]
1707 fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
1708 let mut app = AppConfig::minimal("web", "./srv");
1711 app.instances = 3;
1712 app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
1713 assert!(normalize(app).is_err());
1714 }
1715
1716 #[test]
1717 fn a_name_only_template_does_not_resolve_the_collision() {
1718 let mut app = AppConfig::minimal("web", "./srv");
1722 app.instances = 3;
1723 app.out_file = Some("/var/log/{{name}}.log".to_string());
1724 assert!(normalize(app).is_err());
1725 }
1726
1727 #[test]
1733 fn a_secret_in_a_log_path_is_refused() {
1734 for field in ["out_file", "err_file"] {
1735 let mut app = AppConfig::minimal("web", "./srv");
1736 let path = Some("/var/log/{{secret:TOKEN}}.log".to_string());
1737 if field == "out_file" {
1738 app.out_file = path;
1739 } else {
1740 app.err_file = path;
1741 }
1742 match normalize(app).unwrap_err() {
1743 NormalizeError::SecretInLogPath { name, field: got } => {
1744 assert_eq!(name, "web");
1745 assert_eq!(got, field);
1746 }
1747 other => panic!("expected SecretInLogPath for {field}, got {other:?}"),
1748 }
1749 }
1750 }
1751
1752 #[test]
1756 fn the_positional_tokens_still_render_in_a_log_path() {
1757 let mut app = AppConfig::minimal("web", "./srv");
1758 app.instances = 2;
1759 app.out_file = Some("/var/log/{{name}}-{{instance}}.out".to_string());
1760 app.err_file = Some("/var/log/{{name}}-{{instance}}.err".to_string());
1761 assert!(normalize(app).is_ok());
1762 }
1763
1764 #[test]
1768 fn a_namespaced_secret_in_a_log_path_is_refused_too() {
1769 let mut app = AppConfig::minimal("web", "./srv");
1770 app.err_file = Some("/var/log/{{secret:vercel/TOKEN}}.log".to_string());
1771 assert!(matches!(
1772 normalize(app).unwrap_err(),
1773 NormalizeError::SecretInLogPath { .. }
1774 ));
1775 }
1776
1777 #[test]
1778 fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
1779 let mut app = AppConfig::minimal("web", "./srv");
1780 app.instances = 3;
1781 app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
1782 match normalize(app).unwrap_err() {
1783 NormalizeError::BadTemplate { field, reason, .. } => {
1784 assert_eq!(field, "out_file");
1785 assert!(reason.contains("instnace"), "{reason}");
1786 }
1787 other => panic!("expected BadTemplate, got {other:?}"),
1788 }
1789 }
1790
1791 #[test]
1792 fn watch_options_without_watch_or_cwd_accepted() {
1793 let mut app = AppConfig::minimal("web", "./srv");
1797 app.watch_options = vec!["src/**".to_string()];
1798 assert!(normalize(app).is_ok());
1799 }
1800
1801 #[test]
1802 fn a_sheep_that_depends_on_itself_is_refused_by_name() {
1803 let mut app = AppConfig::minimal("api", "./api");
1806 app.depends_on = vec!["api".to_string()];
1807 match normalize(app) {
1808 Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
1809 other => panic!("expected SelfDependency, got {other:?}"),
1810 }
1811 }
1812
1813 #[test]
1814 fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
1815 let mut app = AppConfig::minimal("api", "./api");
1817 app.depends_on = vec!["db:2".to_string()];
1818 match normalize(app) {
1819 Err(NormalizeError::InstanceDependency { sheep, target }) => {
1820 assert_eq!(sheep, "api");
1821 assert_eq!(target, "db:2");
1822 }
1823 other => panic!("expected InstanceDependency, got {other:?}"),
1824 }
1825 let rendered = NormalizeError::InstanceDependency {
1826 sheep: "api".to_string(),
1827 target: "db:2".to_string(),
1828 }
1829 .to_string();
1830 assert!(
1831 rendered.contains("`db`"),
1832 "the refusal must name the app-level form: {rendered}"
1833 );
1834 }
1835
1836 #[test]
1837 fn duplicate_dependencies_dedupe_rather_than_refusing() {
1838 let mut app = AppConfig::minimal("api", "./api");
1841 app.depends_on = vec!["db".to_string(), "db".to_string()];
1842 let resolved = normalize(app).expect("a repeated name is not an error");
1843 assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
1844 }
1845
1846 #[test]
1847 fn an_ordinary_dependency_list_survives_normalize() {
1848 let mut app = AppConfig::minimal("api", "./api");
1850 app.depends_on = vec!["db".to_string(), "cache".to_string()];
1851 let resolved = normalize(app).expect("an ordinary list normalizes");
1852 assert_eq!(
1853 resolved.config().depends_on,
1854 vec!["db".to_string(), "cache".to_string()]
1855 );
1856 }
1857}