nodus 0.7.1

Local-first CLI for managing project-scoped agent packages.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};

use super::{
    RelaySummary, build_mappings, dependency_context, display_relative, load_workspace,
    relay_dependency_in_dir, resolve_existing_link,
};
use crate::adapters::Adapter;
use crate::lockfile::LOCKFILE_NAME;
use crate::manifest::MANIFEST_FILE;
use crate::report::Reporter;

#[derive(Debug, Clone, PartialEq, Eq)]
struct RelayWatchState {
    config: BTreeMap<PathBuf, PathFingerprint>,
    managed: BTreeMap<String, BTreeMap<PathBuf, PathFingerprint>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum PathFingerprint {
    Missing,
    Directory,
    File([u8; 32]),
}

#[derive(Debug, Clone, Copy)]
pub(super) struct RelayWatchOptions {
    pub(super) poll_interval: Duration,
    pub(super) max_events: Option<usize>,
    pub(super) max_polls: Option<usize>,
}

impl Default for RelayWatchOptions {
    fn default() -> Self {
        Self {
            poll_interval: Duration::from_secs(1),
            max_events: None,
            max_polls: None,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub(super) struct RelayWatchInvocation<'a> {
    pub(super) repo_path_override: Option<&'a Path>,
    pub(super) via_override: Option<Adapter>,
    pub(super) create_missing: bool,
    pub(super) options: RelayWatchOptions,
}

pub(super) fn watch_dependency_in_dir_with_options(
    project_root: &Path,
    cache_root: &Path,
    package: &str,
    invocation: RelayWatchInvocation<'_>,
    reporter: &Reporter,
) -> Result<Vec<RelaySummary>> {
    let packages = vec![package.to_string()];
    watch_dependencies_in_dir_impl_with_options(
        project_root,
        cache_root,
        &packages,
        invocation,
        reporter,
    )
}

pub(super) fn watch_dependencies_in_dir_with_options(
    project_root: &Path,
    cache_root: &Path,
    packages: &[String],
    invocation: RelayWatchInvocation<'_>,
    reporter: &Reporter,
) -> Result<Vec<RelaySummary>> {
    watch_dependencies_in_dir_impl_with_options(
        project_root,
        cache_root,
        packages,
        invocation,
        reporter,
    )
}

fn watch_dependencies_in_dir_impl_with_options(
    project_root: &Path,
    cache_root: &Path,
    packages: &[String],
    invocation: RelayWatchInvocation<'_>,
    reporter: &Reporter,
) -> Result<Vec<RelaySummary>> {
    if packages.is_empty() {
        bail!("relay watch requires at least one dependency");
    }
    if packages.len() > 1 && invocation.repo_path_override.is_some() {
        bail!("`nodus relay --repo-path` requires exactly one dependency");
    }

    let mut summaries = Vec::with_capacity(packages.len());
    for package in packages {
        let summary = relay_dependency_in_dir(
            project_root,
            cache_root,
            package,
            invocation.repo_path_override,
            invocation.via_override,
            invocation.create_missing,
            reporter,
        )?;
        reporter.finish(format!(
            "relayed {} into {}; created {} and updated {} source files",
            summary.alias,
            display_relative(project_root, &summary.linked_repo),
            summary.created_file_count,
            summary.updated_file_count,
        ))?;
        summaries.push(summary);
    }

    let mut state = capture_watch_state(project_root, cache_root, packages, reporter)?;
    reporter.note("watching managed outputs for changes; press Ctrl-C to stop")?;
    let mut polls = 0usize;

    loop {
        if invocation
            .options
            .max_events
            .is_some_and(|max_events| summaries.len() >= max_events)
        {
            return Ok(summaries);
        }
        if invocation
            .options
            .max_polls
            .is_some_and(|max_polls| polls >= max_polls)
        {
            return Ok(summaries);
        }

        thread::sleep(invocation.options.poll_interval);
        polls += 1;

        let next_state = capture_watch_state(project_root, cache_root, packages, reporter)?;
        let config_changed = next_state.config != state.config;
        let changed_packages = changed_watch_packages(&state, &next_state);
        if !config_changed && changed_packages.is_empty() {
            continue;
        }

        state = next_state;
        if changed_packages.is_empty() {
            reporter.note("reloaded relay watch inputs")?;
            continue;
        }

        for package in changed_packages {
            reporter.status("Watching", format!("detected managed edits for {package}"))?;
            let summary = relay_dependency_in_dir(
                project_root,
                cache_root,
                &package,
                None,
                None,
                invocation.create_missing,
                reporter,
            )?;
            reporter.finish(format!(
                "relayed {} into {}; created {} and updated {} source files",
                summary.alias,
                display_relative(project_root, &summary.linked_repo),
                summary.created_file_count,
                summary.updated_file_count,
            ))?;
            summaries.push(summary);
        }
    }
}

fn changed_watch_packages(previous: &RelayWatchState, next: &RelayWatchState) -> Vec<String> {
    let mut aliases = previous
        .managed
        .keys()
        .chain(next.managed.keys())
        .cloned()
        .collect::<BTreeSet<_>>();
    aliases.retain(|alias| previous.managed.get(alias) != next.managed.get(alias));
    aliases.into_iter().collect()
}

fn capture_watch_state(
    project_root: &Path,
    cache_root: &Path,
    packages: &[String],
    reporter: &Reporter,
) -> Result<RelayWatchState> {
    let workspace = load_workspace(project_root, cache_root, reporter)?;
    let managed_names = crate::adapters::ManagedArtifactNames::from_resolved_packages(
        workspace.resolution.packages.iter(),
    );
    let mut managed = BTreeMap::new();
    for package in packages {
        let dependency = dependency_context(&workspace, package)?;
        let linked_repo = resolve_existing_link(&workspace.local_config, &dependency)?;
        let mappings = build_mappings(
            &managed_names,
            &workspace.resolution.packages,
            &dependency,
            &workspace.project_root,
            workspace.selected_adapters,
            &linked_repo,
        )?;

        let mut package_managed = BTreeMap::new();
        for path in mappings.into_iter().map(|mapping| mapping.managed_path) {
            package_managed
                .entry(path.clone())
                .or_insert(path_fingerprint(&path)?);
        }
        managed.insert(dependency.alias.clone(), package_managed);
    }

    let adapter_markers = [
        ".agents",
        ".claude",
        ".codex",
        ".github/skills",
        ".github/agents",
        ".cursor",
        ".opencode",
        "AGENTS.md",
    ];
    let mut config = BTreeMap::new();
    for path in [
        project_root.join(MANIFEST_FILE),
        project_root.join(LOCKFILE_NAME),
        crate::local_config::config_path(project_root),
    ] {
        config.insert(path.clone(), path_fingerprint(&path)?);
    }
    for marker in adapter_markers {
        let path = project_root.join(marker);
        config.insert(path.clone(), path_fingerprint(&path)?);
    }

    Ok(RelayWatchState { config, managed })
}

fn path_fingerprint(path: &Path) -> Result<PathFingerprint> {
    let metadata = match fs::metadata(path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(PathFingerprint::Missing);
        }
        Err(error) => {
            return Err(error).with_context(|| format!("failed to inspect {}", path.display()));
        }
    };

    if metadata.is_dir() {
        return Ok(PathFingerprint::Directory);
    }
    if !metadata.is_file() {
        return Ok(PathFingerprint::Missing);
    }

    let contents = fs::read(path)
        .with_context(|| format!("failed to read watched file {}", path.display()))?;
    let digest = Sha256::digest(contents);
    let mut hash = [0u8; 32];
    hash.copy_from_slice(&digest);
    Ok(PathFingerprint::File(hash))
}