mnml-rs 0.2.14

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
//! Pure free-function helpers extracted from `app/mod.rs` (A-5 of the
//! file-split refactor — 2026-06-28). These functions have no `App`
//! access — they take only their explicit arguments. Lifting them
//! here makes them easier to find and lets `mod.rs` shrink toward the
//! "App state + lifecycle" core it should have been all along.
//!
//! Re-exported from `app/mod.rs` via `pub(crate) use util::*;` so call
//! sites in sibling files (which use `use super::*;`) keep working
//! unchanged.

use std::path::Path;

/// True for files mnml renders as Markdown (md/markdown/mdx/mkd).
pub(crate) fn is_markdown_path(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|e| e.to_str()),
        Some("md" | "markdown" | "mdx" | "mkd")
    )
}

/// True when `path` is the user's home directory (canonicalized). Used
/// by `App::add_workspace_runtime` to detect the empty-state landing
/// and promote the new folder to primary rather than adding as extra.
/// Mirrors the predicate in `ui::tree_view::is_empty_workspace` —
/// keep both in sync.
pub(crate) fn is_home_workspace(path: &Path) -> bool {
    let Some(home) = std::env::var_os("HOME") else {
        return false;
    };
    let home = std::path::PathBuf::from(home);
    let home_c = std::fs::canonicalize(&home).unwrap_or(home);
    path == home_c
}

/// True when `target` looks like a URL (any scheme mnml's external-
/// open path handles). Conservative — only the schemes listed.
pub(crate) fn is_url_like(target: &str) -> bool {
    const SCHEMES: &[&str] = &[
        "http://",
        "https://",
        "mailto:",
        "ftp://",
        "ftps://",
        "file://",
        "ssh://",
        "git://",
        "data:",
        "javascript:",
    ];
    SCHEMES.iter().any(|s| target.starts_with(s))
}

/// True for files mnml renders as inline images (png/jpg/jpeg/gif/
/// webp/bmp). Case-insensitive on the extension.
pub(crate) fn is_image_extension(path: &Path) -> bool {
    matches!(
        path.extension()
            .and_then(|e| e.to_str())
            .map(str::to_ascii_lowercase)
            .as_deref(),
        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
    )
}

/// OS-aware label for "Reveal in `<file browser>`". The underlying
/// `RevealInFinder` `MenuAction` handler shells out to the right
/// system command per OS.
pub(crate) fn reveal_in_files_label() -> &'static str {
    if cfg!(target_os = "macos") {
        "Reveal in Finder"
    } else if cfg!(target_os = "windows") {
        "Reveal in Explorer"
    } else {
        "Reveal in file browser"
    }
}

/// `p` made relative to `workspace` (for `git` arguments). Falls
/// back to `p` if it isn't under `workspace`.
pub(crate) fn rel_path(workspace: &Path, p: &Path) -> String {
    p.strip_prefix(workspace)
        .unwrap_or(p)
        .to_string_lossy()
        .into_owned()
}

/// #20 v4 — count directory entries recursively, capped at `max`
/// so a huge tree doesn't stall the delete prompt. Returns the
/// running total; when the cap is hit, the caller should show
/// `>= max entries` instead of the exact number.
pub(crate) fn walk_entry_count(dir: &Path, depth: u32, max: usize) -> usize {
    if depth > 8 {
        return 0;
    }
    let Ok(entries) = std::fs::read_dir(dir) else {
        return 0;
    };
    let mut total = 0usize;
    for entry in entries.flatten() {
        total += 1;
        if total >= max {
            return max;
        }
        let p = entry.path();
        if p.is_dir() {
            let sub = walk_entry_count(&p, depth + 1, max - total);
            total += sub;
            if total >= max {
                return max;
            }
        }
    }
    total
}

/// Pick the first free `stem-copy[.ext]` / `stem-copy-N[.ext]` name
/// next to `path`. Used by `file.duplicate` + `file.paste` when the
/// destination already exists in the same directory. 2026-07-07.
pub(crate) fn collision_free_copy_name(path: &Path) -> std::path::PathBuf {
    let Some(parent) = path.parent() else {
        return path.to_path_buf();
    };
    let stem_raw = path
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    let ext = path
        .extension()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    // #polish 2026-07-07 (vscode-mouse SEV-3 #12) — strip a trailing
    // `-copy` / `-copy-N` so duplicating `foo-copy.ext` produces
    // `foo-copy-2.ext`, not `foo-copy-copy.ext`. Matches Finder /
    // VS Code duplicate semantics.
    let stem = if let Some(base) = stem_raw
        .rsplit_once("-copy-")
        .and_then(|(base, tail)| tail.parse::<usize>().ok().map(|_| base))
    {
        base.to_string()
    } else if let Some(base) = stem_raw.strip_suffix("-copy") {
        base.to_string()
    } else {
        stem_raw
    };
    let make = |suffix: &str| {
        if ext.is_empty() {
            parent.join(format!("{stem}{suffix}"))
        } else {
            parent.join(format!("{stem}{suffix}.{ext}"))
        }
    };
    let first = make("-copy");
    if !first.exists() && first != path {
        return first;
    }
    for n in 2..1000 {
        let candidate = make(&format!("-copy-{n}"));
        if !candidate.exists() && candidate != path {
            return candidate;
        }
    }
    // Astronomical fallback — 1000 copies of the same file in one dir.
    make("-copy-lots")
}

/// Recursive `cp` — files use `fs::copy`, directories walk children.
/// Returns Err with a human-readable string on the first failure.
pub(crate) fn copy_recursively(src: &Path, dst: &Path) -> Result<(), String> {
    let meta =
        std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?;
    if meta.is_dir() {
        std::fs::create_dir_all(dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
        for entry in
            std::fs::read_dir(src).map_err(|e| format!("read_dir {}: {e}", src.display()))?
        {
            let entry = entry.map_err(|e| e.to_string())?;
            let child_src = entry.path();
            let Some(name) = child_src.file_name() else {
                continue;
            };
            let child_dst = dst.join(name);
            copy_recursively(&child_src, &child_dst)?;
        }
        Ok(())
    } else if meta.file_type().is_symlink() {
        let target = std::fs::read_link(src).map_err(|e| e.to_string())?;
        #[cfg(unix)]
        {
            std::os::unix::fs::symlink(&target, dst)
                .map_err(|e| format!("symlink {}: {e}", dst.display()))?;
        }
        // Windows symlinks require admin/dev-mode; fall back to
        // copying the resolved target (matches Git-for-Windows
        // behavior — visible file content, no dangling reference).
        #[cfg(windows)]
        {
            let resolved = if target.is_absolute() {
                target
            } else {
                src.parent()
                    .unwrap_or(std::path::Path::new("."))
                    .join(&target)
            };
            std::fs::copy(&resolved, dst)
                .map(|_| ())
                .map_err(|e| format!("symlink-as-copy {}: {e}", dst.display()))?;
        }
        Ok(())
    } else {
        std::fs::copy(src, dst)
            .map(|_| ())
            .map_err(|e| format!("copy {}{}: {e}", src.display(), dst.display()))
    }
}

/// Resolve `input` to an absolute path — `~` expands to the user's
/// home dir, relative paths join to `workspace`.
pub(crate) fn expand_tilde_and_resolve(workspace: &Path, input: &str) -> std::path::PathBuf {
    if let Some(rest) = input.strip_prefix("~/")
        && let Some(home) = std::env::var_os("HOME")
    {
        return std::path::PathBuf::from(home).join(rest);
    }
    if input == "~"
        && let Some(home) = std::env::var_os("HOME")
    {
        return std::path::PathBuf::from(home);
    }
    let p = std::path::PathBuf::from(input);
    if p.is_absolute() {
        p
    } else {
        workspace.join(p)
    }
}

/// Walk `text` and return every `(row, col_chars, len_chars)` for a
/// whole-word occurrence of `word`. Char columns (not byte) so the
/// renderer's per-cell painter can align without re-decoding UTF-8.
/// Caps at 5000 hits — a hard safeguard against pathological cases
/// (every occurrence of `the` in a novel-sized file).
pub fn collect_whole_word_occurrences(text: &str, word: &str) -> Vec<(usize, usize, usize)> {
    let word_chars: Vec<char> = word.chars().collect();
    if word_chars.is_empty() {
        return Vec::new();
    }
    let wlen = word_chars.len();
    let is_id = |c: char| c.is_alphanumeric() || c == '_';
    let mut out = Vec::new();
    for (row, line) in text.split('\n').enumerate() {
        let chars: Vec<char> = line.chars().collect();
        let n = chars.len();
        if n < wlen {
            continue;
        }
        let mut i = 0;
        while i + wlen <= n {
            if chars[i..i + wlen] == word_chars[..]
                && (i == 0 || !is_id(chars[i - 1]))
                && (i + wlen == n || !is_id(chars[i + wlen]))
            {
                out.push((row, i, wlen));
                if out.len() >= 5000 {
                    return out;
                }
                i += wlen;
            } else {
                i += 1;
            }
        }
    }
    out
}

/// Snap a byte offset to the nearest char boundary at or before
/// `byte`. crash-investigator 2026-06-28 SEV-3 fix: session restore
/// reads cursor_byte from disk; if the file was externally edited
/// to put astral-plane (4-byte) UTF-8 characters at the prior
/// position, slicing `text[..byte]` mid-char would panic. Always
/// snap before slicing.
fn snap_to_char_boundary(text: &str, byte: usize) -> usize {
    let byte = byte.min(text.len());
    // text.is_char_boundary(text.len()) is always true; this loop
    // terminates by byte == 0 at the latest. UTF-8 chars are at
    // most 4 bytes wide, so this scans at most 3 steps backward
    // — O(1) in practice despite the unbounded-looking range.
    // code-reviewer 3rd 2026-06-29 N-2: comment-clarifies the
    // false-alarm O(n) read.
    (0..=byte)
        .rev()
        .find(|&b| text.is_char_boundary(b))
        .unwrap_or(0)
}

/// Byte offset → `(line, col_chars)`. Used by find / search / mark
/// flows to convert match positions into editor-cursor coords.
pub(crate) fn byte_to_line_col(text: &str, byte: usize) -> (usize, usize) {
    let cap = snap_to_char_boundary(text, byte);
    let line = text[..cap].bytes().filter(|&b| b == b'\n').count();
    let line_start = text[..cap].rfind('\n').map(|i| i + 1).unwrap_or(0);
    let col = text[line_start..cap].chars().count();
    (line, col)
}

/// Synonym of `byte_to_line_col` — kept for the snippet / LSP edit
/// sites that named the line as "row".
pub(crate) fn byte_to_row_col(text: &str, byte: usize) -> (usize, usize) {
    let byte = snap_to_char_boundary(text, byte);
    let row = text[..byte].bytes().filter(|&b| b == b'\n').count();
    let line_start = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
    let col = text[line_start..byte].chars().count();
    (row, col)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn byte_to_row_col_snaps_mid_emoji_to_boundary() {
        // crash-investigator 2026-06-28 SEV-3: a session-restored
        // cursor_byte landing inside a multi-byte char must NOT
        // panic. The snap-to-boundary helper keeps the slice safe.
        let text = "a\u{1F600}b"; // a + 😀 (4 bytes) + b — 6 bytes total
        // Boundaries: 0 (a), 1 (start of 😀), 5 (start of b), 6 (end).
        // byte=3 lands inside 😀 — should snap back to 1.
        let (row, col) = byte_to_row_col(text, 3);
        assert_eq!(row, 0);
        assert_eq!(col, 1, "cursor lands between 'a' and the emoji");
        // byte=4 (still inside 😀) snaps to 1.
        let (row, col) = byte_to_row_col(text, 4);
        assert_eq!((row, col), (0, 1));
        // byte=6 (end) stays at end → col 3 (a + emoji + b = 3 chars).
        let (row, col) = byte_to_row_col(text, 6);
        assert_eq!((row, col), (0, 3));
        // Past-end clamps.
        let (row, col) = byte_to_row_col(text, 999);
        assert_eq!((row, col), (0, 3));
    }

    #[test]
    fn byte_to_line_col_snaps_too() {
        let text = "x\u{1F600}\nfoo";
        let (line, col) = byte_to_line_col(text, 3);
        assert_eq!((line, col), (0, 1));
        let (line, col) = byte_to_line_col(text, 8);
        assert_eq!((line, col), (1, 2));
    }
}