From 9efdb9e3c1ed361b50f6f3a4691c7c03a54f62a0 Mon Sep 17 00:00:00 2001 From: yuk1ty Date: Wed, 15 Jul 2026 23:49:02 +0900 Subject: [PATCH] feat: add validation checking phase and remove validate plugin - Add validation checking phase and cli command - Refactor config loading to separate reading from parsing and validation - Remove validate subcommand plugin (results only available via notification, unintuitive for users); keep validate.rs as pre-check in apply path --- ARCHITECTURE.md | 10 +- src/backend/cli.rs | 8 +- src/cli.rs | 9 + src/config.rs | 52 ++--- src/engine.rs | 51 +---- src/lib.rs | 1 + src/main.rs | 60 +++--- src/validate.rs | 518 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 603 insertions(+), 106 deletions(-) create mode 100644 src/validate.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e21fbed..dc1b33e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,7 +31,13 @@ main.rs ──▶ backend/cli.rs ──implements──▶ backend/mod.rs ``` config.yaml (workspaces: list, required; config.yml also accepted) - │ config::load_config → fs::read_to_string + SpreadFile::from_str (serde_yaml_ng) + │ config::read_config → fs::read_to_string + ▼ +String (raw file contents, ownership kept by the caller) + │ (apply path) validate::validate_config(SourceFile { yaml, path }) + │ → SpreadFile::from_str + validate (serde_yaml_ng) + │ — any finding (error or warning) stops processing (Err/exit 1); + │ only Ok proceeds to path resolution and the engine ▼ SpreadFile { workspaces: Vec } (raw, as written by the user) │ config::resolve_paths(file, env, invocation_cwd) @@ -154,7 +160,7 @@ If you're touching path resolution, add a test for the *specific* combination yo Three layers, each targeting a different seam: -1. **`config.rs` unit tests** — YAML parsing and path resolution, as plain data-in/data-out assertions. No filesystem or process access beyond `load_config`'s own file read. +1. **`config.rs` unit tests** — YAML parsing and path resolution, as plain data-in/data-out assertions. No filesystem or process access beyond `read_config`'s own file read. 2. **`engine.rs` unit tests** — drive `apply_workspace` (and `apply`'s cross-workspace focus-selection folding on top of it) against a hand-written `MockBackend` that just records every call it receives (`Vec`). This is what makes it possible to assert "for this YAML, exactly these `HerdrBackend` calls happen, in this order, with these ids" without needing herdr installed at all. 3. **`tests/cli_backend_integration.rs`** — the one test that exercises the full `engine::apply` → `CliBackend` → subprocess path, against `tests/fixtures/fake-herdr.sh`, a script that logs the argv it's called with and echoes back canned JSON shaped like real herdr responses. This is what catches integration bugs the mock backend can't see (e.g. an argv-building bug in `backend/cli.rs` that both the mock and the real herdr would parse differently). diff --git a/src/backend/cli.rs b/src/backend/cli.rs index 1ea45aa..5758e17 100644 --- a/src/backend/cli.rs +++ b/src/backend/cli.rs @@ -619,7 +619,9 @@ mod tests { BackendError::Herdr { message } => { assert!(message.contains("unknown variant")); } - other => panic!("expected BackendError::Herdr, got {other:?}"), + other @ BackendError::CommandFailed { .. } => { + panic!("expected BackendError::Herdr, got {other:?}") + } } } @@ -635,7 +637,9 @@ mod tests { assert_eq!(code, Some(7)); assert!(stderr.contains("boom")); } - other => panic!("expected BackendError::CommandFailed, got {other:?}"), + other @ BackendError::Herdr { .. } => { + panic!("expected BackendError::CommandFailed, got {other:?}") + } } } diff --git a/src/cli.rs b/src/cli.rs index bfd7e8c..74c914e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -46,4 +46,13 @@ mod tests { } ); } + + #[test] + fn should_reject_validate_subcommand() { + let result = Cli::try_parse_from(["herdr-spreader", "validate"]); + assert!( + result.is_err(), + "validate subcommand should no longer be parsed" + ); + } } diff --git a/src/config.rs b/src/config.rs index 405d786..7aa0498 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,12 +90,6 @@ pub enum ConfigError { #[source] source: std::io::Error, }, - #[error("failed to parse config file {path}: {source}")] - Parse { - path: PathBuf, - #[source] - source: serde_yaml_ng::Error, - }, } const CONFIG_FILE_NAMES: &[&str] = &["config.yaml", "config.yml"]; @@ -155,18 +149,13 @@ fn config_candidate_paths(env: &BTreeMap) -> Vec { paths } -/// Load a [`SpreadFile`] from a YAML file on disk. +/// Read a config file from disk into a `String`. /// /// # Errors /// -/// Returns [`ConfigError::Io`] if the file cannot be read, or -/// [`ConfigError::Parse`] if its contents are not valid YAML. -pub fn load_config(path: &Path) -> Result { - let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io { - path: path.to_path_buf(), - source, - })?; - SpreadFile::from_str(&contents).map_err(|source| ConfigError::Parse { +/// Returns [`ConfigError::Io`] if the file cannot be read. +pub fn read_config(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_path_buf(), source, }) @@ -243,11 +232,11 @@ mod tests { #[test] fn should_parse_two_workspaces_given_multi_workspace_yaml() { - let yaml = r#" + let yaml = r" workspaces: - name: frontend - name: backend -"#; +"; let file = SpreadFile::from_str(yaml).unwrap(); @@ -270,12 +259,12 @@ workspaces: #[test] fn should_parse_workspace_level_focus_flag_and_default_it_to_false() { - let yaml = r#" + let yaml = r" workspaces: - name: frontend - name: backend focus: true -"#; +"; let file = SpreadFile::from_str(yaml).unwrap(); @@ -285,10 +274,10 @@ workspaces: #[test] fn should_parse_workspace_name_given_minimal_yaml_with_only_name() { - let yaml = r#" + let yaml = r" workspaces: - name: demo -"#; +"; let file = SpreadFile::from_str(yaml).unwrap(); @@ -299,7 +288,7 @@ workspaces: #[test] fn should_parse_tabs_and_panes_given_tmuxinator_style_yaml() { - let yaml = r#" + let yaml = r" workspaces: - name: demo tabs: @@ -309,7 +298,7 @@ workspaces: - label: server panes: - command: cargo run -"#; +"; let file = SpreadFile::from_str(yaml).unwrap(); @@ -323,13 +312,13 @@ workspaces: #[test] fn should_default_split_to_right_and_ratio_to_none_when_omitted() { - let yaml = r#" + let yaml = r" workspaces: - name: demo tabs: - panes: - command: nvim -"#; +"; let file = SpreadFile::from_str(yaml).unwrap(); @@ -342,14 +331,14 @@ workspaces: #[test] fn should_reject_config_given_unknown_split_direction() { - let yaml = r#" + let yaml = r" workspaces: - name: demo tabs: - panes: - command: nvim split: left -"#; +"; let result = SpreadFile::from_str(yaml); @@ -385,7 +374,7 @@ workspaces: fn should_include_file_path_in_error_when_config_file_is_missing() { let path = Path::new("/nonexistent/path/to/config.yaml"); - let result = load_config(path); + let result = read_config(path); let err = result.unwrap_err(); assert!(err.to_string().contains("/nonexistent/path/to/config.yaml")); @@ -393,18 +382,19 @@ workspaces: #[test] fn should_load_spread_file_from_disk_given_multi_workspace_yaml() { - let yaml = r#" + let yaml = r" workspaces: - name: frontend - name: backend -"#; +"; let path = std::env::temp_dir().join(format!( "herdr-spreader-test-{}-should_load_spread_file_from_disk_given_multi_workspace_yaml.yml", std::process::id() )); fs::write(&path, yaml).unwrap(); - let file = load_config(&path).unwrap(); + let yaml = read_config(&path).unwrap(); + let file = SpreadFile::from_str(&yaml).unwrap(); assert_eq!(file.workspaces.len(), 2); diff --git a/src/engine.rs b/src/engine.rs index 30c3190..1b49e76 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -69,8 +69,6 @@ fn normalize_path(path: &Path) -> PathBuf { pub enum EngineError { #[error(transparent)] Backend(#[from] BackendError), - #[error("wait_for was specified on a pane without a command")] - WaitForWithoutCommand, } /// Apply the workspace layout described by `file`, creating workspaces, tabs, @@ -83,9 +81,7 @@ pub enum EngineError { /// /// # Errors /// -/// Returns [`EngineError::Backend`] if any backend operation fails, or -/// [`EngineError::WaitForWithoutCommand`] if a pane specifies `wait_for` -/// without a `command`. +/// Returns [`EngineError::Backend`] if any backend operation fails. pub fn apply(file: &SpreadFile, backend: &mut dyn HerdrBackend) -> Result<(), EngineError> { for ws in &file.workspaces { apply_workspace(ws, backend)?; @@ -99,9 +95,7 @@ pub fn apply(file: &SpreadFile, backend: &mut dyn HerdrBackend) -> Result<(), En /// /// # Errors /// -/// Returns [`EngineError::Backend`] if any backend operation fails, or -/// [`EngineError::WaitForWithoutCommand`] if a pane specifies `wait_for` -/// without a `command`. +/// Returns [`EngineError::Backend`] if any backend operation fails. pub fn apply_workspace(ws: &Workspace, backend: &mut dyn HerdrBackend) -> Result<(), EngineError> { let first_pane_focus = ws .tabs @@ -181,15 +175,10 @@ pub fn apply_workspace(ws: &Workspace, backend: &mut dyn HerdrBackend) -> Result if let Some(wait_for) = &pane.wait_for { backend.wait_output(&pane_id, wait_for)?; } - } else { - if pane.wait_for.is_some() { - return Err(EngineError::WaitForWithoutCommand); - } - if pane_index == 0 - && let Some(prefix) = cwd_env_prefix(resolved_cwd.as_deref(), &pane.env) - { - backend.run(&pane_id, &prefix)?; - } + } else if pane_index == 0 + && let Some(prefix) = cwd_env_prefix(resolved_cwd.as_deref(), &pane.env) + { + backend.run(&pane_id, &prefix)?; } previous_pane_id = pane_id; @@ -759,34 +748,6 @@ mod tests { ); } - #[test] - fn should_error_when_wait_for_is_set_on_a_pane_without_a_command() { - let config = Workspace { - name: "demo".to_string(), - tabs: vec![Tab { - label: None, - panes: vec![Pane { - command: None, - wait_for: Some(WaitFor { - pattern: "ready".to_string(), - timeout_ms: None, - }), - ..Default::default() - }], - ..Default::default() - }], - ..Default::default() - }; - let mut mock = MockBackend::default(); - - let result = engine::apply_workspace(&config, &mut mock); - - assert!(matches!( - result, - Err(engine::EngineError::WaitForWithoutCommand) - )); - } - #[test] fn should_pass_focus_to_create_tab_when_pane_has_focus_true_in_non_first_tab() { let ws = Workspace { diff --git a/src/lib.rs b/src/lib.rs index ad254af..309d368 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,4 @@ pub mod backend; pub mod cli; pub mod config; pub mod engine; +pub mod validate; diff --git a/src/main.rs b/src/main.rs index efaaaf0..b5e3896 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,36 +4,44 @@ use clap::Parser; use herdr_spreader::backend::cli::CliBackend; use herdr_spreader::cli::{Cli, Command}; -use herdr_spreader::config::{load_config, resolve_config_path, resolve_paths}; +use herdr_spreader::config::{read_config, resolve_config_path, resolve_paths}; use herdr_spreader::engine; +use herdr_spreader::validate; fn main() -> anyhow::Result<()> { let env: BTreeMap = std::env::vars().collect(); let cli = Cli::parse(); - let Command::Apply { file } = cli.command; - - let config_path = resolve_config_path(file, &env)?; - let spread_file = load_config(&config_path)?; - - let bin = CliBackend::resolve_bin(&env); - let mut backend = CliBackend::new(bin); - - // Under `herdr plugin action invoke`, the process cwd is the plugin's own - // install directory, not the user's workspace. Query the invoking pane's - // real shell directory over the herdr CLI instead; fall back to the - // process cwd for standalone CLI usage (no herdr session, or the query - // fails) where the process cwd is the correct invocation directory. - let cwd = match env - .get("HERDR_PANE_ID") - .and_then(|id| backend.query_pane_cwd(id)) - { - Some(cwd) => cwd, - None => std::env::current_dir()?, - }; - let spread_file = resolve_paths(spread_file, &env, &cwd); - - engine::apply(&spread_file, &mut backend)?; - - Ok(()) + match cli.command { + Command::Apply { file } => { + let config_path = resolve_config_path(file, &env)?; + let contents = read_config(&config_path)?; + let spread_file = match validate::validate_config(validate::SourceFile { + yaml: &contents, + path: &config_path, + }) { + Ok(f) => f, + Err(findings) => { + validate::print_findings(&findings); + std::process::exit(1); + } + }; + + let bin = CliBackend::resolve_bin(&env); + let mut backend = CliBackend::new(bin); + + let cwd = match env + .get("HERDR_PANE_ID") + .and_then(|id| backend.query_pane_cwd(id)) + { + Some(cwd) => cwd, + None => std::env::current_dir()?, + }; + let spread_file = resolve_paths(spread_file, &env, &cwd); + + engine::apply(&spread_file, &mut backend)?; + + Ok(()) + } + } } diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..7ba5dcf --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,518 @@ +use std::collections::HashSet; +use std::path::Path; + +use crate::config::SpreadFile; + +#[derive(Debug, Clone, PartialEq)] +pub enum Severity { + Error, + Warning, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ValidationFinding { + pub severity: Severity, + pub message: String, +} + +#[derive(Debug, Clone, Copy)] +pub struct SourceFile<'a> { + pub yaml: &'a str, + pub path: &'a Path, +} + +/// Validate a config file's contents: parse the YAML, then run semantic checks. +/// +/// # Errors +/// +/// Returns a `Vec` if the YAML fails to parse (a single +/// `Severity::Error` finding naming `source.path`) or if any semantic finding +/// (error *or* warning) is reported; otherwise returns the parsed [`SpreadFile`]. +pub fn validate_config(source: SourceFile<'_>) -> Result> { + let file = SpreadFile::from_str(source.yaml).map_err(|err| { + vec![ValidationFinding { + severity: Severity::Error, + message: format!( + "failed to parse config file {}: {err}", + source.path.display() + ), + }] + })?; + let findings = validate(&file); + if findings.is_empty() { + Ok(file) + } else { + Err(findings) + } +} + +pub(crate) fn validate(file: &SpreadFile) -> Vec { + let mut findings = Vec::new(); + + let mut seen_names: HashSet<&str> = HashSet::new(); + let mut focus_count = 0usize; + + for ws in &file.workspaces { + if ws.name.is_empty() { + findings.push(ValidationFinding { + severity: Severity::Error, + message: "workspace name must not be empty".to_string(), + }); + } + if !seen_names.insert(ws.name.as_str()) { + findings.push(ValidationFinding { + severity: Severity::Error, + message: format!("duplicate workspace name '{}'", ws.name), + }); + } + if ws.focus { + focus_count += 1; + } + + for tab in &ws.tabs { + if tab.panes.is_empty() { + findings.push(ValidationFinding { + severity: Severity::Warning, + message: format!( + "tab '{}' in workspace '{}' has no panes", + tab.label.as_deref().unwrap_or("(unnamed)"), + ws.name + ), + }); + } + + for pane in &tab.panes { + if let Some(ratio) = pane.ratio + && (ratio <= 0.0 || ratio >= 1.0) + { + findings.push(ValidationFinding { + severity: Severity::Error, + message: format!( + "pane ratio {ratio} in workspace '{}' must be between 0 and 1 (exclusive)", + ws.name + ), + }); + } + + if pane.wait_for.is_some() && pane.command.is_none() { + findings.push(ValidationFinding { + severity: Severity::Error, + message: format!( + "wait_for was specified on a pane without a command in workspace '{}'", + ws.name + ), + }); + } + } + } + } + + if focus_count > 1 { + findings.push(ValidationFinding { + severity: Severity::Warning, + message: format!( + "{focus_count} workspaces have `focus: true`; only the last one will receive focus" + ), + }); + } + + findings +} + +pub fn print_findings(findings: &[ValidationFinding]) { + for finding in findings { + let tag = match finding.severity { + Severity::Error => "ERROR", + Severity::Warning => "WARNING", + }; + eprintln!("[{tag}] {}", finding.message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Pane, SpreadFile, Tab, WaitFor, Workspace}; + + #[test] + fn should_return_empty_findings_list_given_valid_minimal_config() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + ..Default::default() + }], + }; + let findings = validate(&file); + assert!(findings.is_empty()); + } + + #[test] + fn should_report_error_given_workspace_with_empty_name() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: String::new(), + ..Default::default() + }], + }; + let findings = validate(&file); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Error); + assert!(findings[0].message.contains("empty")); + } + + #[test] + fn should_report_error_given_duplicate_workspace_names() { + let file = SpreadFile { + workspaces: vec![ + Workspace { + name: "frontend".to_string(), + ..Default::default() + }, + Workspace { + name: "frontend".to_string(), + ..Default::default() + }, + ], + }; + let findings = validate(&file); + let dup_findings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("duplicate")) + .collect(); + assert_eq!(dup_findings.len(), 1); + assert_eq!(dup_findings[0].severity, Severity::Error); + } + + #[test] + fn should_report_error_given_ratio_equal_to_zero() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + ratio: Some(0.0), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Error); + } + + #[test] + fn should_report_error_given_ratio_equal_to_one() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + ratio: Some(1.0), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Error); + } + + #[test] + fn should_report_error_given_negative_ratio() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + ratio: Some(-0.5), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Error); + } + + #[test] + fn should_skip_ratio_check_when_ratio_is_none() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + ratio: None, + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + assert!(findings.is_empty()); + } + + #[test] + fn should_accept_valid_ratio_between_zero_and_one() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + ratio: Some(0.5), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + assert!(findings.is_empty()); + } + + #[test] + fn should_report_warning_given_multiple_focused_workspaces() { + let file = SpreadFile { + workspaces: vec![ + Workspace { + name: "alpha".to_string(), + focus: true, + ..Default::default() + }, + Workspace { + name: "beta".to_string(), + focus: false, + ..Default::default() + }, + Workspace { + name: "gamma".to_string(), + focus: true, + ..Default::default() + }, + ], + }; + let findings = validate(&file); + let focus_warnings: Vec<_> = findings + .iter() + .filter(|f| f.severity == Severity::Warning && f.message.contains("focus")) + .collect(); + assert_eq!(focus_warnings.len(), 1); + } + + #[test] + fn should_not_report_warning_given_exactly_one_focused_workspace() { + let file = SpreadFile { + workspaces: vec![ + Workspace { + name: "alpha".to_string(), + focus: true, + ..Default::default() + }, + Workspace { + name: "beta".to_string(), + ..Default::default() + }, + ], + }; + let findings = validate(&file); + let focus_warnings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("focus")) + .collect(); + assert!(focus_warnings.is_empty()); + } + + #[test] + fn should_not_report_warning_given_zero_focused_workspaces() { + let file = SpreadFile { + workspaces: vec![ + Workspace { + name: "alpha".to_string(), + ..Default::default() + }, + Workspace { + name: "beta".to_string(), + ..Default::default() + }, + ], + }; + let findings = validate(&file); + let focus_warnings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("focus")) + .collect(); + assert!(focus_warnings.is_empty()); + } + + #[test] + fn should_report_warning_given_tab_with_no_panes() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + label: None, + panes: vec![], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + let tab_warnings: Vec<_> = findings + .iter() + .filter(|f| f.severity == Severity::Warning && f.message.contains("no panes")) + .collect(); + assert_eq!(tab_warnings.len(), 1); + } + + #[test] + fn should_not_report_warning_given_tab_with_panes() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + label: None, + panes: vec![Pane { + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + let tab_warnings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("no panes")) + .collect(); + assert!(tab_warnings.is_empty()); + } + + #[test] + fn should_report_error_given_pane_with_wait_for_but_no_command() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + command: None, + wait_for: Some(WaitFor { + pattern: "ready".to_string(), + timeout_ms: None, + }), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + let wait_findings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("wait_for")) + .collect(); + assert_eq!(wait_findings.len(), 1); + assert_eq!(wait_findings[0].severity, Severity::Error); + } + + #[test] + fn should_not_report_wait_for_error_given_pane_with_both_command_and_wait_for() { + let file = SpreadFile { + workspaces: vec![Workspace { + name: "demo".to_string(), + tabs: vec![Tab { + panes: vec![Pane { + command: Some("cargo run".to_string()), + wait_for: Some(WaitFor { + pattern: "ready".to_string(), + timeout_ms: None, + }), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }], + }; + let findings = validate(&file); + let wait_findings: Vec<_> = findings + .iter() + .filter(|f| f.message.contains("wait_for")) + .collect(); + assert!(wait_findings.is_empty()); + } + + #[test] + fn should_return_ok_with_spread_file_given_clean_yaml() { + let source = SourceFile { + yaml: "workspaces:\n - name: demo\n", + path: Path::new("config.yaml"), + }; + let result = validate_config(source); + assert!(result.is_ok()); + assert_eq!(result.unwrap().workspaces[0].name, "demo"); + } + + #[test] + fn should_return_err_with_parse_finding_given_malformed_yaml() { + let source = SourceFile { + yaml: "name: demo\ntabs: []", + path: Path::new("/cfg/spread.yaml"), + }; + let result = validate_config(source); + let err = result.unwrap_err(); + assert_eq!(err.len(), 1); + assert_eq!(err[0].severity, Severity::Error); + assert!(err[0].message.contains("/cfg/spread.yaml")); + assert!(err[0].message.to_lowercase().contains("parse")); + } + + #[test] + fn should_return_err_with_findings_given_duplicate_workspace_names() { + let source = SourceFile { + yaml: "workspaces:\n - name: frontend\n - name: frontend\n", + path: Path::new("c.yaml"), + }; + let result = validate_config(source); + let err = result.unwrap_err(); + assert!( + err.iter() + .any(|f| f.message.contains("duplicate") && f.severity == Severity::Error) + ); + } + + #[test] + fn should_return_err_given_warning_only_config_with_empty_tab() { + let source = SourceFile { + yaml: "workspaces:\n - name: demo\n tabs:\n - panes: []\n", + path: Path::new("c.yaml"), + }; + let result = validate_config(source); + let err = result.unwrap_err(); + assert!( + err.iter() + .any(|f| f.severity == Severity::Warning && f.message.contains("no panes")) + ); + } + + #[test] + fn should_pass_validation_given_bundled_example_config() { + let source = SourceFile { + yaml: include_str!("../examples/config.yaml"), + path: Path::new("examples/config.yaml"), + }; + assert!(validate_config(source).is_ok()); + } +}