//! Top-level orchestrator: walk from a starting directory to a
//! fully-loaded [`DiscoveredWorkspace`].
//!
//! [`Discovery::discover_workspace`] chains the per-phase
//! discovery methods (`find_workspace_root`,
//! `load_workspace_config`, `expand_project_list`,
//! `load_projects` for explicit mode; `load_implicit_project` for
//! implicit mode) and bundles the outcome into a single value.
//!
//! Each phase short-circuits to the corresponding
//! [`DiscoveryError`] variant on its first failure. Within a
//! phase, the existing accumulate-vs-fail-fast policies remain
//! unchanged ([`Discovery::expand_project_list`]'s
//! [`ExpandErrors`] accumulates across patterns,
//! [`Discovery::load_projects`]'s [`LoadErrors`] accumulates
//! across roots, etc.).

use std::path::Path;

use haz_vfs::Filesystem;
use snafu::Snafu;

use crate::discovery::Discovery;
use crate::overlays::load::{LoadOverlayErrors, LoadedOverlayList};
use crate::projects::expand::ExpandErrors;
use crate::projects::load::{LoadError, LoadErrors, LoadedProjectList};
use crate::root::{RootError, WorkspaceRoot};
use crate::workspace::load::{LoadedWorkspaceConfig, WorkspaceLoadError};

/// The fully-loaded outcome of a successful discovery run.
///
/// Bundles the resolved workspace root, the (optional) loaded
/// workspace configuration, and the loaded projects. Implicit-mode
/// runs carry `workspace_config = None` (per `DISC-003`, implicit
/// mode does not load `.haz/workspace.yml`); explicit-mode runs
/// carry `Some(...)`.
pub struct DiscoveredWorkspace<F: Filesystem> {
    /// The workspace root chosen by [`Discovery::find_workspace_root`].
    pub workspace: WorkspaceRoot<F>,
    /// The parsed `<root>/.haz/workspace.yml`. `None` in implicit
    /// mode.
    pub workspace_config: Option<LoadedWorkspaceConfig>,
    /// The loaded projects.
    pub projects: LoadedProjectList<F>,
    /// The loaded overlay files from `<root>/.haz/tasks/`.
    /// Empty in implicit mode (no `.haz/` directory exists) and
    /// also empty when explicit-mode workspaces have no
    /// `.haz/tasks/` directory or no surviving overlay files.
    pub overlays: LoadedOverlayList<F>,
}

// Manual trait impls (same rationale as elsewhere in this crate:
// derives would add a bound on `F` itself; we want bounds on
// `F::CanonicalPath`).

impl<F: Filesystem> std::fmt::Debug for DiscoveredWorkspace<F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DiscoveredWorkspace")
            .field("workspace", &self.workspace)
            .field("workspace_config", &self.workspace_config)
            .field("projects", &self.projects)
            .field("overlays", &self.overlays)
            .finish()
    }
}

/// Errors produced by [`Discovery::discover_workspace`].
///
/// Each variant wraps the typed error from one of the per-phase
/// methods, preserving its diagnostic information. Diagnostic
/// codes follow the `haz::dsc::NNN` namespace, continuing the
/// allocation after the project loader's
/// ([`Discovery::load_projects`]) `023..027`.
#[derive(Debug, Snafu)]
pub enum DiscoveryError {
    /// Workspace-root walk (`find_workspace_root`) failed. Inner
    /// [`RootError`] discriminates the cause (no marker reached,
    /// relative start, filesystem error, etc.).
    ///
    /// Diagnostic code: `haz::dsc::028`.
    #[snafu(display("workspace root discovery failed"))]
    FindRoot {
        /// Underlying root-walk error.
        source: RootError,
    },

    /// Loading `<root>/.haz/workspace.yml` failed in explicit
    /// mode. Inner [`WorkspaceLoadError`] discriminates the
    /// failure mode (missing file, invalid UTF-8, parse error
    /// with byte-anchored span).
    ///
    /// Diagnostic code: `haz::dsc::029`.
    #[snafu(display("workspace config load failed"))]
    LoadWorkspaceConfig {
        /// Underlying workspace-config-load error.
        source: WorkspaceLoadError,
    },

    /// Project-list expansion (`expand_project_list`) failed in
    /// explicit mode. Inner [`ExpandErrors`] contains every
    /// pattern-level failure accumulated in the single run.
    ///
    /// Diagnostic code: `haz::dsc::030`.
    #[snafu(display("project expansion failed"))]
    ExpandProjects {
        /// Underlying expansion errors.
        source: ExpandErrors,
    },

    /// Per-root project loading (`load_projects`) failed in
    /// explicit mode. Inner [`LoadErrors`] contains every
    /// per-root failure (read, UTF-8, parse, projection,
    /// duplicate-name) accumulated in the single run.
    ///
    /// Diagnostic code: `haz::dsc::031`.
    #[snafu(display("project load failed"))]
    LoadProjects {
        /// Underlying load errors.
        source: LoadErrors,
    },

    /// Implicit-mode singleton-project load
    /// (`load_implicit_project`) failed. Inner [`LoadError`]
    /// discriminates the failure mode.
    ///
    /// Diagnostic code: `haz::dsc::032`.
    #[snafu(display("implicit-mode project load failed"))]
    LoadImplicitProject {
        /// Underlying load error.
        source: LoadError,
    },

    /// Overlay loading (`load_overlays`) failed in explicit mode.
    /// Inner [`LoadOverlayErrors`] contains every per-file
    /// failure accumulated in the single run.
    ///
    /// Diagnostic code: `haz::dsc::037`.
    #[snafu(display("overlay loading failed"))]
    LoadOverlays {
        /// Underlying overlay-load errors.
        source: LoadOverlayErrors,
    },
}

// Empty Diagnostic impl: callers can construct
// `miette::Report::new(err)` directly. The chain rendering is
// driven by `std::error::Error::source` (which `snafu` derives
// for every variant). Rich `code`/`labels`/`help`/`source_code`
// belong to the inner `ProjectMapError` / `WorkspaceMapError` /
// `OverlayMapError` reached via the `Parse` variants; the
// caller that wants those is responsible for promoting the
// inner diagnostic to a fresh `Report`.
impl miette::Diagnostic for DiscoveryError {}

impl<F: Filesystem> Discovery<'_, F> {
    /// Run end-to-end discovery from a starting directory.
    ///
    /// Walks `start`'s ancestors to find the workspace root, then
    /// dispatches on the mode:
    ///
    /// - [`WorkspaceRoot::Explicit`]: load `<root>/.haz/workspace.yml`,
    ///   expand its `projects` list, then load each resolved
    ///   project's `haz.yml`.
    /// - [`WorkspaceRoot::Implicit`]: load the singleton project
    ///   at `<root>/haz.yml`.
    ///
    /// Returns a [`DiscoveredWorkspace`] bundling the workspace
    /// root, the optional loaded workspace config (explicit mode
    /// only), and the loaded projects.
    ///
    /// # Errors
    ///
    /// Returns the [`DiscoveryError`] variant for the first
    /// failing phase. Within each phase, the existing
    /// per-phase accumulation policies remain in effect
    /// ([`Discovery::expand_project_list`] accumulates across
    /// patterns; [`Discovery::load_projects`] accumulates across
    /// roots).
    pub fn discover_workspace(
        &self,
        start: &Path,
    ) -> Result<DiscoveredWorkspace<F>, DiscoveryError> {
        let workspace = self
            .find_workspace_root(start)
            .map_err(|source| DiscoveryError::FindRoot { source })?;

        match &workspace {
            WorkspaceRoot::Explicit { lexical, .. } => {
                let config = self
                    .load_workspace_config(lexical)
                    .map_err(|source| DiscoveryError::LoadWorkspaceConfig { source })?;
                let resolved = self
                    .expand_project_list(&workspace, &config.config.projects)
                    .map_err(|source| DiscoveryError::ExpandProjects { source })?;
                let projects = self
                    .load_projects(&workspace, &resolved)
                    .map_err(|source| DiscoveryError::LoadProjects { source })?;
                let overlays = self
                    .load_overlays(&workspace, &projects)
                    .map_err(|source| DiscoveryError::LoadOverlays { source })?;
                Ok(DiscoveredWorkspace {
                    workspace,
                    workspace_config: Some(config),
                    projects,
                    overlays,
                })
            }
            WorkspaceRoot::Implicit { canonical, .. } => {
                let projects = self
                    .load_implicit_project(canonical)
                    .map_err(|source| DiscoveryError::LoadImplicitProject { source })?;
                Ok(DiscoveredWorkspace {
                    workspace,
                    workspace_config: None,
                    projects,
                    overlays: LoadedOverlayList::new_empty(),
                })
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::path::{Path, PathBuf};

    use indoc::indoc;

    use haz_vfs::{DirEntry, EntryKind, Filesystem};
    use haz_vfs_testing::MemFilesystem;

    use crate::discovery::Discovery;
    use crate::root::WorkspaceRoot;

    use super::DiscoveryError;

    /// Structural snapshot of one filesystem entry, ignoring fields
    /// (atime, mtime, ctime) that DISC-030 explicitly permits to
    /// change as an incidental side-effect of a `read` / `stat`.
    ///
    /// Two entries compare equal iff their kind, byte contents (for
    /// regular files), mode (for regular files), and link target
    /// (for symlinks) coincide.
    #[derive(Debug, Clone, PartialEq, Eq)]
    enum EntrySnapshot {
        Dir,
        File { contents: Vec<u8>, mode: u32 },
        Symlink { target: PathBuf },
        Other(EntryKind),
    }

    /// Walk every reachable entry under `root` and produce a
    /// deterministic `path -> snapshot` map. Used by DISC-030 tests
    /// to assert byte-for-byte filesystem invariance across a
    /// discovery call.
    ///
    /// Symlinks are recorded by their `read_link` target without
    /// being followed (so a symlink cycle does not loop the walk);
    /// every reachable directory IS descended into.
    fn snapshot_tree(fs: &MemFilesystem, root: &Path) -> BTreeMap<PathBuf, EntrySnapshot> {
        let mut out = BTreeMap::new();
        let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
        while let Some(current) = stack.pop() {
            let meta = fs
                .metadata(&current)
                .unwrap_or_else(|e| panic!("metadata({}) failed: {e}", current.display()));
            match meta.kind {
                EntryKind::Dir => {
                    out.insert(current.clone(), EntrySnapshot::Dir);
                    let entries: Vec<DirEntry> = fs
                        .read_dir(&current)
                        .unwrap_or_else(|e| panic!("read_dir({}) failed: {e}", current.display()));
                    for entry in entries {
                        stack.push(entry.path);
                    }
                }
                EntryKind::File => {
                    let contents = fs.read(&current).unwrap();
                    let mode = fs.mode_of(&current).unwrap();
                    out.insert(current, EntrySnapshot::File { contents, mode });
                }
                EntryKind::Symlink => {
                    let target = fs.read_link(&current).unwrap();
                    out.insert(current, EntrySnapshot::Symlink { target });
                }
                other => {
                    out.insert(current, EntrySnapshot::Other(other));
                }
            }
        }
        out
    }

    // Explicit-mode happy path -------------------------------------------

    #[test]
    fn explicit_happy_path_two_projects() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
                  - /apps/web
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: lib_core\n".to_vec())
            .unwrap();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_dir("/ws/apps/web").unwrap();
        fs.add_file("/ws/apps/web/haz.yml", b"name: web\n".to_vec())
            .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();

        assert!(matches!(result.workspace, WorkspaceRoot::Explicit { .. }));
        assert!(result.workspace_config.is_some());
        assert_eq!(result.projects.len(), 2);
        let names: Vec<&str> = result
            .projects
            .iter()
            .map(|lp| lp.project().name.as_ref())
            .collect();
        assert_eq!(names, vec!["lib_core", "web"]);
    }

    // Implicit-mode happy path -------------------------------------------

    #[test]
    fn implicit_happy_path_single_project() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.git").unwrap();
        fs.add_file("/ws/haz.yml", b"name: only_proj\n".to_vec())
            .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();

        assert!(matches!(result.workspace, WorkspaceRoot::Implicit { .. }));
        assert!(result.workspace_config.is_none());
        assert_eq!(result.projects.len(), 1);
        let lp = result.projects.iter().next().unwrap();
        assert_eq!(lp.project().name.as_ref(), "only_proj");
        assert!(lp.project().root.is_workspace_root());
    }

    // Phase failures: FindRoot ------------------------------------------

    #[test]
    fn find_root_failure_surfaces_find_root_variant() {
        let fs = MemFilesystem::new();
        // No markers anywhere -> NoWorkspaceRoot.
        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/some/where"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::FindRoot { .. }));
    }

    #[test]
    fn relative_start_surfaces_find_root_variant() {
        let fs = MemFilesystem::new();
        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("relative/path"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::FindRoot { .. }));
    }

    // Phase failures: LoadWorkspaceConfig --------------------------------

    #[test]
    fn malformed_workspace_yml_surfaces_load_workspace_config_variant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            b"projects: [unterminated\n".to_vec(),
        )
        .unwrap();

        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadWorkspaceConfig { .. }));
    }

    // Phase failures: ExpandProjects -------------------------------------

    #[test]
    fn missing_literal_target_surfaces_expand_projects_variant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /missing
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::ExpandProjects { .. }));
    }

    // Phase failures: LoadProjects ---------------------------------------

    #[test]
    fn missing_project_haz_yml_surfaces_load_projects_variant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        // No haz.yml for lib_core.

        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadProjects { .. }));
    }

    // Phase failures: LoadImplicitProject --------------------------------

    #[test]
    fn implicit_mode_missing_haz_yml_surfaces_load_implicit_project_variant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.git").unwrap();
        // No /ws/haz.yml.

        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadImplicitProject { .. }));
    }

    // Phase outcomes: overlays ----------------------------------------------

    #[test]
    fn explicit_mode_with_no_overlay_dir_yields_empty_overlays() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: lib_core\n".to_vec())
            .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        assert!(result.overlays.is_empty());
    }

    #[test]
    fn explicit_mode_with_one_overlay_loads_it() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: lib_core\n".to_vec())
            .unwrap();
        fs.add_dir("/ws/.haz/tasks").unwrap();
        fs.add_file(
            "/ws/.haz/tasks/lint.yml",
            indoc! {"
                tasks:
                  lint:
                    command: [echo, lint]
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        assert_eq!(result.overlays.len(), 1);
        let lo = result.overlays.iter().next().unwrap();
        assert_eq!(lo.overlay().name.as_ref(), "lint");
        let matched: Vec<&str> = lo
            .overlay()
            .matched_projects
            .iter()
            .map(AsRef::as_ref)
            .collect();
        assert_eq!(matched, vec!["lib_core"]);
    }

    #[test]
    fn implicit_mode_has_empty_overlays() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.git").unwrap();
        fs.add_file("/ws/haz.yml", b"name: only_proj\n".to_vec())
            .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        assert!(result.overlays.is_empty());
    }

    #[test]
    fn malformed_overlay_surfaces_load_overlays_variant() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: lib_core\n".to_vec())
            .unwrap();
        fs.add_dir("/ws/.haz/tasks").unwrap();
        fs.add_file(
            "/ws/.haz/tasks/bad.yml",
            indoc! {"
                tagsFilter: []
                tasks: {}
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();

        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadOverlays { .. }));
    }

    // PathBuf comparison sanity check (defensive: the orchestrator
    // should pass paths through unchanged).

    #[test]
    fn explicit_happy_path_workspace_root_is_canonicalised() {
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file("/ws/.haz/workspace.yml", b"projects: []\n".to_vec())
            .unwrap();

        let result = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        let lexical = result.workspace.lexical();
        assert_eq!(lexical.as_path(), PathBuf::from("/ws"));
    }

    // DISC-030 -- discovery is read-only ---------------------------------

    #[test]
    fn disc_030_explicit_mode_discovery_does_not_mutate_filesystem() {
        // DISC-030: discovery MUST NOT create, modify, rename, or
        // delete any filesystem entry. The byte-for-byte snapshot
        // before and after MUST agree on every reachable entry's
        // kind, content, mode, and (for symlinks) target.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.haz").unwrap();
        fs.add_file(
            "/ws/.haz/workspace.yml",
            indoc! {"
                projects:
                  - /lib_core
                  - /apps/web
            "}
            .as_bytes()
            .to_vec(),
        )
        .unwrap();
        fs.add_dir("/ws/lib_core").unwrap();
        fs.add_file("/ws/lib_core/haz.yml", b"name: lib_core\n".to_vec())
            .unwrap();
        fs.add_dir("/ws/apps").unwrap();
        fs.add_dir("/ws/apps/web").unwrap();
        fs.add_file("/ws/apps/web/haz.yml", b"name: web\n".to_vec())
            .unwrap();

        let before = snapshot_tree(&fs, Path::new("/ws"));
        let _ = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        let after = snapshot_tree(&fs, Path::new("/ws"));

        assert_eq!(
            before, after,
            "DISC-030: explicit-mode discovery must be read-only",
        );
    }

    #[test]
    fn disc_030_implicit_mode_discovery_does_not_mutate_filesystem() {
        // DISC-030 in implicit mode: the `.git` marker plus a single
        // project's `haz.yml` at the workspace root. Same invariant.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/ws").unwrap();
        fs.add_dir("/ws/.git").unwrap();
        fs.add_file("/ws/haz.yml", b"name: root\n".to_vec())
            .unwrap();

        let before = snapshot_tree(&fs, Path::new("/ws"));
        let _ = Discovery::new(&fs)
            .discover_workspace(Path::new("/ws"))
            .unwrap();
        let after = snapshot_tree(&fs, Path::new("/ws"));

        assert_eq!(
            before, after,
            "DISC-030: implicit-mode discovery must be read-only",
        );
    }

    #[test]
    fn disc_030_failing_discovery_does_not_mutate_filesystem() {
        // DISC-030 holds even when discovery fails: a missing-root
        // walk MUST NOT create scaffolding as a side-effect. The
        // failure observation must leave the tree untouched.
        let mut fs = MemFilesystem::new();
        fs.add_dir("/").unwrap(); // ensure root exists, no markers
        // /unrelated has neither `.haz` nor `.git` between it and /;
        // the walk must surface FindRoot but write nothing.
        fs.add_dir("/unrelated").unwrap();

        let before = snapshot_tree(&fs, Path::new("/"));
        let err = Discovery::new(&fs)
            .discover_workspace(Path::new("/unrelated"))
            .expect_err("workspace without markers must surface DiscoveryError");
        assert!(matches!(err, DiscoveryError::FindRoot { .. }));
        let after = snapshot_tree(&fs, Path::new("/"));

        assert_eq!(
            before, after,
            "DISC-030: failing discovery must also be read-only",
        );
    }
}

#[cfg(all(test, unix))]
mod std_smoke_tests {
    //! Smoke tests against [`StdFilesystem`] backed by a
    //! `tempfile::TempDir`. These re-run a subset of the
    //! [`MemFilesystem`]-based orchestrator scenarios against a
    //! real on-disk directory tree, catching divergences between
    //! the two backends (canonicalisation, symlink handling,
    //! error mapping, real-OS reads).

    use std::fs as std_fs;
    use std::os::unix::fs::symlink;

    use tempfile::TempDir;

    use haz_vfs::StdFilesystem;

    use super::{DiscoveredWorkspace, DiscoveryError};
    use crate::discovery::Discovery;
    use crate::root::WorkspaceRoot;

    #[test]
    fn std_explicit_two_projects_end_to_end() {
        let tmp = TempDir::new().unwrap();
        let root = std_fs::canonicalize(tmp.path()).unwrap();
        std_fs::create_dir(root.join(".haz")).unwrap();
        std_fs::write(
            root.join(".haz/workspace.yml"),
            "projects:\n  - /lib_core\n  - /apps/web\n",
        )
        .unwrap();
        std_fs::create_dir(root.join("lib_core")).unwrap();
        std_fs::write(root.join("lib_core/haz.yml"), "name: lib_core\n").unwrap();
        std_fs::create_dir(root.join("apps")).unwrap();
        std_fs::create_dir(root.join("apps/web")).unwrap();
        std_fs::write(root.join("apps/web/haz.yml"), "name: web\n").unwrap();

        let fs = StdFilesystem;
        let DiscoveredWorkspace {
            workspace,
            workspace_config,
            projects,
            overlays: _,
        } = Discovery::new(&fs).discover_workspace(&root).unwrap();

        assert!(matches!(workspace, WorkspaceRoot::Explicit { .. }));
        assert!(workspace_config.is_some());
        assert_eq!(projects.len(), 2);

        let mut names: Vec<&str> = projects
            .iter()
            .map(|lp| lp.project().name.as_ref())
            .collect();
        names.sort_unstable();
        assert_eq!(names, vec!["lib_core", "web"]);
    }

    #[test]
    fn std_implicit_single_project_end_to_end() {
        let tmp = TempDir::new().unwrap();
        let root = std_fs::canonicalize(tmp.path()).unwrap();
        // .git as a regular-file gitlink marks implicit mode.
        std_fs::write(root.join(".git"), "gitdir: /elsewhere\n").unwrap();
        std_fs::write(root.join("haz.yml"), "name: only_proj\n").unwrap();

        let fs = StdFilesystem;
        let DiscoveredWorkspace {
            workspace,
            workspace_config,
            projects,
            overlays: _,
        } = Discovery::new(&fs).discover_workspace(&root).unwrap();

        assert!(matches!(workspace, WorkspaceRoot::Implicit { .. }));
        assert!(workspace_config.is_none());
        assert_eq!(projects.len(), 1);
        let lp = projects.iter().next().unwrap();
        assert_eq!(lp.project().name.as_ref(), "only_proj");
        assert!(lp.project().root.is_workspace_root());
    }

    #[test]
    fn std_project_reached_via_symlink_canonicalises_to_real_dir() {
        // Validates DISC-020 against a real on-disk symlink: a
        // project entry pointing at a symlink resolves to the
        // canonical target.
        let tmp = TempDir::new().unwrap();
        let root = std_fs::canonicalize(tmp.path()).unwrap();
        std_fs::create_dir(root.join(".haz")).unwrap();
        std_fs::write(
            root.join(".haz/workspace.yml"),
            "projects:\n  - /lib_core\n",
        )
        .unwrap();
        // Real target lives at /tmp_root/real_lib; lib_core is a
        // symlink that points at it.
        std_fs::create_dir(root.join("real_lib")).unwrap();
        std_fs::write(root.join("real_lib/haz.yml"), "name: lib_core\n").unwrap();
        symlink(root.join("real_lib"), root.join("lib_core")).unwrap();

        let fs = StdFilesystem;
        let DiscoveredWorkspace { projects, .. } =
            Discovery::new(&fs).discover_workspace(&root).unwrap();
        assert_eq!(projects.len(), 1);
        let lp = projects.iter().next().unwrap();
        assert_eq!(lp.project().name.as_ref(), "lib_core");
        // The host_root should be the canonical target, not the
        // symlink path.
        assert_eq!(lp.host_root().as_path(), root.join("real_lib").as_path());
    }

    #[test]
    fn std_implicit_missing_haz_yml_surfaces_load_implicit_project_variant() {
        let tmp = TempDir::new().unwrap();
        let root = std_fs::canonicalize(tmp.path()).unwrap();
        std_fs::write(root.join(".git"), "gitdir: /elsewhere\n").unwrap();
        // No haz.yml.

        let fs = StdFilesystem;
        let err = Discovery::new(&fs).discover_workspace(&root).unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadImplicitProject { .. }));
    }

    #[test]
    fn std_explicit_workspace_yml_parse_error_surfaces_load_workspace_config() {
        let tmp = TempDir::new().unwrap();
        let root = std_fs::canonicalize(tmp.path()).unwrap();
        std_fs::create_dir(root.join(".haz")).unwrap();
        std_fs::write(root.join(".haz/workspace.yml"), "projects: [unterminated\n").unwrap();

        let fs = StdFilesystem;
        let err = Discovery::new(&fs).discover_workspace(&root).unwrap_err();
        assert!(matches!(err, DiscoveryError::LoadWorkspaceConfig { .. }));
    }
}
