regy 0.1.1

Private-by-default desktop agent for the Regy web interface
use std::{
    collections::{BTreeMap, HashSet},
    env,
    ffi::OsString,
    fs,
    os::unix::fs::PermissionsExt,
    path::PathBuf,
    time::Duration,
};

use semver::Version;

use crate::{
    dependencies::command::{CommandOutput, CommandRunner, CommandSpec},
    domain::errors::{AgentError, AgentResult, ErrorCode},
};

const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_PROBE_STREAM_BYTES: usize = 1024 * 1024;

pub(crate) const NODE_VERSION_POLICY: VersionPolicy = VersionPolicy {
    minimum_inclusive: "22.19.0",
    maximum_exclusive: "23.0.0",
};
pub(crate) const PI_VERSION_POLICY: VersionPolicy = VersionPolicy {
    minimum_inclusive: "0.80.10",
    maximum_exclusive: "0.81.0",
};
pub(crate) const CLAUDE_VERSION_POLICY: VersionPolicy = VersionPolicy {
    minimum_inclusive: "2.1.212",
    maximum_exclusive: "3.0.0",
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExecutableSource {
    Explicit,
    Path,
    Managed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedExecutable {
    pub path: PathBuf,
    pub version: String,
    pub source: ExecutableSource,
    pub skipped: Vec<String>,
}

pub(crate) struct ExecutableRequirement {
    pub name: &'static str,
    pub explicit: Option<PathBuf>,
    pub managed: PathBuf,
    pub trusted_managed_explicit: bool,
    pub version_args: &'static [&'static str],
    pub version_policy: VersionPolicy,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct VersionPolicy {
    pub minimum_inclusive: &'static str,
    pub maximum_exclusive: &'static str,
}

pub(crate) struct ExecutableResolver<'a> {
    runner: &'a dyn CommandRunner,
    path: Option<OsString>,
}

impl<'a> ExecutableResolver<'a> {
    pub(crate) fn with_path(runner: &'a dyn CommandRunner, path: Option<OsString>) -> Self {
        Self { runner, path }
    }

    pub(crate) async fn resolve(
        &self,
        requirement: ExecutableRequirement,
    ) -> AgentResult<Option<ResolvedExecutable>> {
        let minimum = Version::parse(requirement.version_policy.minimum_inclusive)
            .map_err(|_| invalid_policy(requirement.name))?;

        let mut candidates = Vec::new();
        if let Some(explicit) = requirement.explicit {
            let source = if requirement.trusted_managed_explicit && explicit == requirement.managed
            {
                ExecutableSource::Managed
            } else {
                ExecutableSource::Explicit
            };
            candidates.push((source, explicit));
        }
        if let Some(path) = &self.path {
            candidates.extend(
                env::split_paths(path)
                    .filter(|directory| !directory.as_os_str().is_empty())
                    .map(|directory| (ExecutableSource::Path, directory.join(requirement.name))),
            );
        }
        candidates.push((ExecutableSource::Managed, requirement.managed));

        let mut skipped = Vec::new();
        let mut visited = HashSet::new();
        for (source, candidate) in candidates {
            let Some(canonical) = canonical_executable(&candidate) else {
                if source != ExecutableSource::Path || candidate.symlink_metadata().is_ok() {
                    skipped.push(format!(
                        "{} {} candidate is not a canonical regular executable",
                        source_label(source),
                        requirement.name
                    ));
                }
                continue;
            };
            if !visited.insert(canonical.clone()) {
                continue;
            }

            let spec = CommandSpec {
                program: canonical.clone(),
                args: requirement
                    .version_args
                    .iter()
                    .map(OsString::from)
                    .collect(),
                env: BTreeMap::new(),
                cwd: None,
                timeout: VERSION_PROBE_TIMEOUT,
            };
            let output = match self.runner.output(&spec).await {
                Ok(output) if output.status == 0 => output,
                Ok(_) | Err(_) => {
                    skipped.push(format!(
                        "{} {} version probe failed",
                        source_label(source),
                        requirement.name
                    ));
                    continue;
                }
            };
            let Some(version) = version_from_output(&output) else {
                skipped.push(format!(
                    "{} {} version output was invalid",
                    source_label(source),
                    requirement.name
                ));
                continue;
            };
            if !version.pre.is_empty() || version < minimum {
                skipped.push(format!(
                    "{} {} version {} is outside >= {}",
                    source_label(source),
                    requirement.name,
                    numeric_version(&version),
                    requirement.version_policy.minimum_inclusive,
                ));
                continue;
            }

            return Ok(Some(ResolvedExecutable {
                path: if source == ExecutableSource::Managed {
                    candidate
                } else {
                    canonical
                },
                version: version.to_string(),
                source,
                skipped,
            }));
        }

        Ok(None)
    }
}

fn canonical_executable(candidate: &PathBuf) -> Option<PathBuf> {
    let canonical = fs::canonicalize(candidate).ok()?;
    let metadata = fs::metadata(&canonical).ok()?;
    (metadata.is_file() && metadata.permissions().mode() & 0o111 != 0).then_some(canonical)
}

fn version_from_output(output: &CommandOutput) -> Option<Version> {
    if output.stdout.len() > MAX_PROBE_STREAM_BYTES || output.stderr.len() > MAX_PROBE_STREAM_BYTES
    {
        return None;
    }
    let stdout = std::str::from_utf8(&output.stdout).ok()?;
    let stderr = std::str::from_utf8(&output.stderr).ok()?;
    first_dotted_version(stdout).or_else(|| first_dotted_version(stderr))
}

fn first_dotted_version(output: &str) -> Option<Version> {
    let bytes = output.as_bytes();
    let mut start = 0;
    while start < bytes.len() {
        if !bytes[start].is_ascii_digit() || !has_left_version_boundary(output, start) {
            start += 1;
            continue;
        }

        let mut end = consume_digits(bytes, start);
        if bytes.get(end) != Some(&b'.') {
            start += 1;
            continue;
        }
        end = consume_digits(bytes, end + 1);
        if bytes.get(end) != Some(&b'.') {
            start += 1;
            continue;
        }
        end = consume_digits(bytes, end + 1);
        if bytes.get(end.saturating_sub(1)) == Some(&b'.') {
            start += 1;
            continue;
        }
        let token_end = if matches!(bytes.get(end), Some(b'-' | b'+')) {
            consume_semver_suffix(bytes, end)
        } else {
            end
        };
        if !has_right_version_boundary(output, token_end) {
            start += 1;
            continue;
        }
        if let Ok(version) = Version::parse(&output[start..token_end]) {
            return Some(version);
        }
        start = token_end.max(start + 1);
    }
    None
}

fn consume_digits(bytes: &[u8], mut index: usize) -> usize {
    while matches!(bytes.get(index), Some(b'0'..=b'9')) {
        index += 1;
    }
    index
}

fn consume_semver_suffix(bytes: &[u8], mut index: usize) -> usize {
    while matches!(
        bytes.get(index),
        Some(byte)
            if byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'-' | b'+')
    ) {
        index += 1;
    }
    index
}

fn has_left_version_boundary(output: &str, start: usize) -> bool {
    let Some(previous) = output[..start].chars().next_back() else {
        return true;
    };
    if matches!(previous, 'v' | 'V') {
        let prefix_start = start - previous.len_utf8();
        return output[..prefix_start]
            .chars()
            .next_back()
            .is_none_or(is_version_delimiter);
    }
    is_version_delimiter(previous)
}

fn has_right_version_boundary(output: &str, end: usize) -> bool {
    output[end..]
        .chars()
        .next()
        .is_none_or(is_version_delimiter)
}

fn is_version_delimiter(character: char) -> bool {
    !character.is_alphanumeric() && !matches!(character, '_' | '.')
}

fn source_label(source: ExecutableSource) -> &'static str {
    match source {
        ExecutableSource::Explicit => "explicit",
        ExecutableSource::Path => "PATH",
        ExecutableSource::Managed => "managed",
    }
}

fn numeric_version(version: &Version) -> String {
    format!("{}.{}.{}", version.major, version.minor, version.patch)
}

fn invalid_policy(name: &str) -> AgentError {
    AgentError::new(
        ErrorCode::InvalidMessage,
        format!("invalid version policy for {name}"),
    )
}