malvin 0.2.5

Non-interactive research and coding agent
use std::path::{Path, PathBuf};

use super::work_dir_for_path;

fn md_path_has_invalid_characters(s: &str) -> bool {
    if s.contains('\0') {
        return true;
    }
    #[cfg(windows)]
    if s.contains(['<', '>', ':', '"', '|', '?', '*']) {
        return true;
    }
    false
}

fn md_path_has_invalid_components(path: &Path) -> bool {
    path.components().any(|c| {
        matches!(
            c,
            std::path::Component::CurDir | std::path::Component::ParentDir
        )
    })
}

#[must_use]
#[allow(clippy::case_sensitive_file_extension_comparisons)]
pub fn looks_like_md_file_path_arg(arg: &str) -> bool {
    let trimmed = arg.trim();
    !trimmed.is_empty()
        && !trimmed.chars().any(char::is_whitespace)
        && trimmed.ends_with(".md")
        && !md_path_has_invalid_characters(trimmed)
        && !md_path_has_invalid_components(Path::new(trimmed))
}

fn md_path_is_within_cwd(cwd: &Path, path: &Path) -> bool {
    let Ok(cwd_canon) = cwd.canonicalize() else {
        return false;
    };
    let Ok(path_canon) = path.canonicalize() else {
        return false;
    };
    path_canon == cwd_canon || path_canon.starts_with(&cwd_canon)
}

#[must_use]
#[allow(clippy::case_sensitive_file_extension_comparisons)]
pub fn is_existing_md_file_path(arg: &str) -> Option<PathBuf> {
    if !looks_like_md_file_path_arg(arg) {
        return None;
    }
    let trimmed = arg.trim();
    let path = Path::new(trimmed);
    let cwd = std::env::current_dir().ok()?;
    let resolved = if path.is_absolute() {
        path.to_path_buf()
    } else {
        cwd.join(path)
    };
    if !resolved.is_file() {
        return None;
    }
    if !md_path_is_within_cwd(&cwd, &resolved) {
        return None;
    }
    Some(resolved)
}

pub fn resolve_user_md_request(arg: &str) -> Result<(String, PathBuf), String> {
    if let Some(path) = is_existing_md_file_path(arg) {
        let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
        return Ok((text, work_dir_for_path(&path)));
    }
    Ok((arg.trim().to_string(), PathBuf::from(".")))
}

#[cfg(test)]
mod tests {
    use super::{
        looks_like_md_file_path_arg, md_path_has_invalid_characters,
        md_path_has_invalid_components, resolve_user_md_request,
    };
    use std::path::Path;

    #[test]
    fn looks_like_md_file_path_arg_rejects_null_dot_parent_and_space() {
        assert!(looks_like_md_file_path_arg("plan.md"));
        assert!(looks_like_md_file_path_arg("  nested/plan.md  "));
        assert!(!looks_like_md_file_path_arg(""));
        assert!(!looks_like_md_file_path_arg("plan.mdx"));
        assert!(!looks_like_md_file_path_arg("my plan.md"));
        assert!(
            !looks_like_md_file_path_arg("bad\0.md"),
            "NUL must not be treated as a path"
        );
        assert!(!looks_like_md_file_path_arg("../escape.md"));
        assert!(!looks_like_md_file_path_arg("./local.md"));
        assert!(md_path_has_invalid_characters("x\0y"));
        assert!(!md_path_has_invalid_characters("ok.md"));
        assert!(md_path_has_invalid_components(Path::new("../x.md")));
        assert!(md_path_has_invalid_components(Path::new("./x.md")));
        assert!(!md_path_has_invalid_components(Path::new("dir/x.md")));
    }

    #[test]
    fn resolve_user_md_request_rejects_symlink_outside_cwd() {
        #[cfg(unix)]
        {
            use super::is_existing_md_file_path;
            use std::os::unix::fs::symlink;
            let _guard = crate::test_utils::test_env_lock();
            let tmp = tempfile::tempdir().unwrap();
            let outside = tempfile::tempdir().unwrap();
            std::fs::write(outside.path().join("secret.md"), "stolen").unwrap();
            let old_cwd = std::env::current_dir().unwrap();
            std::env::set_current_dir(tmp.path()).unwrap();
            symlink(
                outside.path().join("secret.md"),
                tmp.path().join("steal.md"),
            )
            .unwrap();
            assert!(is_existing_md_file_path("steal.md").is_none());
            let (text, wd) = resolve_user_md_request("steal.md").unwrap();
            assert_eq!(text, "steal.md");
            assert_eq!(wd, std::path::PathBuf::from("."));
            std::env::set_current_dir(old_cwd).unwrap();
        }
    }

    #[test]
    fn resolve_user_md_request_treats_nul_path_as_literal_text() {
        let raw = "bad\0.md";
        let (text, wd) = resolve_user_md_request(raw).expect("literal");
        assert_eq!(text, raw);
        assert_eq!(wd, std::path::PathBuf::from("."));
    }
}