lla 0.6.4

Blazing Fast and highly customizable ls Replacement with Superpowers
use lla_plugin_interface::proto::DecoratedEntry;
use serde::Serialize;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};

use chrono::{SecondsFormat, TimeZone, Utc};
#[cfg(unix)]
use once_cell::sync::Lazy;
#[cfg(unix)]
use std::sync::Mutex;
#[cfg(unix)]
use users::{get_group_by_gid, get_user_by_uid};

#[cfg(unix)]
static USER_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
#[cfg(unix)]
static GROUP_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));

#[derive(Debug, Serialize)]
pub struct SerializableEntry {
    pub path: String,
    pub name: String,
    pub extension: Option<String>,
    pub file_type: String,
    pub size_bytes: u64,
    pub modified: String,
    pub created: Option<String>,
    pub accessed: Option<String>,
    pub mode_octal: String,
    pub owner_user: Option<String>,
    pub owner_group: Option<String>,
    pub inode: Option<u64>,
    pub hard_links: Option<u64>,
    pub allocated_size_bytes: Option<u64>,
    pub xattrs: BTreeMap<String, u64>,
    pub has_acl: bool,
    pub security_context: Option<String>,
    pub mount_point: Option<String>,
    pub mount_source: Option<String>,
    pub filesystem: Option<String>,
    pub symlink_target: Option<String>,
    pub is_hidden: bool,
    pub git_status: Option<String>,
    pub plugin: HashMap<String, serde_json::Value>,
}

fn fmt_ts_opt(secs: u64) -> Option<String> {
    if secs == 0 {
        return None;
    }
    let dt = Utc.timestamp_opt(secs as i64, 0).single();
    dt.map(|d| d.to_rfc3339_opts(SecondsFormat::Secs, true))
}

fn fmt_ts_required(secs: u64) -> String {
    let dt = Utc.timestamp_opt(secs as i64, 0).single();
    dt.map(|d| d.to_rfc3339_opts(SecondsFormat::Secs, true))
        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
}

fn mode_to_octal(permissions: u32) -> String {
    format!("{:04o}", permissions & 0o7777)
}

#[cfg(unix)]
fn uid_to_name(uid: u32) -> Option<String> {
    if uid == 0 && get_user_by_uid(uid).is_none() {
        return None;
    }
    let Ok(mut cache) = USER_CACHE.lock() else {
        return get_user_by_uid(uid).map(|u| u.name().to_string_lossy().into_owned());
    };
    if let Some(name) = cache.get(&uid) {
        return Some(name.clone());
    }
    let name = get_user_by_uid(uid).map(|u| u.name().to_string_lossy().into_owned());
    if let Some(ref n) = name {
        cache.insert(uid, n.clone());
    }
    name
}

#[cfg(windows)]
fn uid_to_name(_uid: u32) -> Option<String> {
    None
}

#[cfg(unix)]
fn gid_to_name(gid: u32) -> Option<String> {
    if gid == 0 && get_group_by_gid(gid).is_none() {
        return None;
    }
    let Ok(mut cache) = GROUP_CACHE.lock() else {
        return get_group_by_gid(gid).map(|g| g.name().to_string_lossy().into_owned());
    };
    if let Some(name) = cache.get(&gid) {
        return Some(name.clone());
    }
    let name = get_group_by_gid(gid).map(|g| g.name().to_string_lossy().into_owned());
    if let Some(ref n) = name {
        cache.insert(gid, n.clone());
    }
    name
}

#[cfg(windows)]
fn gid_to_name(_gid: u32) -> Option<String> {
    None
}

pub fn to_serializable(entry: &DecoratedEntry, git_status: Option<String>) -> SerializableEntry {
    let path = Path::new(&entry.path);
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string();
    let extension = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|s| s.to_string());

    let md = entry.metadata.as_ref().cloned().unwrap_or_default();

    let file_type = if md.is_symlink {
        "symlink"
    } else if md.is_dir {
        "dir"
    } else if md.is_file {
        "file"
    } else {
        "other"
    }
    .to_string();

    let inode = (md.inode != 0).then_some(md.inode);
    let hard_links = (md.hard_links != 0).then_some(md.hard_links);
    let allocated_size_bytes = (md.inode != 0).then_some(md.allocated_size);

    let symlink_target = if md.is_symlink {
        if let Some(t) = entry.custom_fields.get("symlink_target") {
            Some(t.clone())
        } else if let Ok(target) = fs::read_link(&entry.path) {
            Some(target.to_string_lossy().into_owned())
        } else {
            None
        }
    } else {
        None
    };

    let is_hidden = name.starts_with('.');

    let owner_user = uid_to_name(md.uid);
    let owner_group = gid_to_name(md.gid);

    let mut plugin: HashMap<String, serde_json::Value> = HashMap::new();
    for (k, v) in &entry.custom_fields {
        plugin.insert(k.clone(), serde_json::Value::String(v.clone()));
    }
    fn typed_value_to_json(value: &lla_plugin_interface::proto::TypedValue) -> serde_json::Value {
        let Some(value) = &value.value else {
            return serde_json::Value::Null;
        };
        match value {
            lla_plugin_interface::proto::typed_value::Value::NullValue(_) => {
                serde_json::Value::Null
            }
            lla_plugin_interface::proto::typed_value::Value::StringValue(value)
            | lla_plugin_interface::proto::typed_value::Value::PathValue(value) => {
                serde_json::Value::String(value.clone())
            }
            lla_plugin_interface::proto::typed_value::Value::IntegerValue(value) => {
                serde_json::Value::Number((*value).into())
            }
            lla_plugin_interface::proto::typed_value::Value::FloatValue(value) => {
                serde_json::Number::from_f64(*value)
                    .map(serde_json::Value::Number)
                    .unwrap_or(serde_json::Value::Null)
            }
            lla_plugin_interface::proto::typed_value::Value::BooleanValue(value) => {
                serde_json::Value::Bool(*value)
            }
            lla_plugin_interface::proto::typed_value::Value::BytesValue(value)
            | lla_plugin_interface::proto::typed_value::Value::TimestampValue(value) => {
                serde_json::Value::Number((*value).into())
            }
            lla_plugin_interface::proto::typed_value::Value::ListValue(values) => {
                serde_json::Value::Array(values.values.iter().map(typed_value_to_json).collect())
            }
            lla_plugin_interface::proto::typed_value::Value::ObjectValue(value) => {
                serde_json::Value::Object(
                    value
                        .fields
                        .iter()
                        .map(|(key, value)| (key.clone(), typed_value_to_json(value)))
                        .collect(),
                )
            }
        }
    }
    for (key, value) in &entry.typed_fields {
        plugin.insert(key.clone(), typed_value_to_json(value));
    }

    SerializableEntry {
        path: entry.path.clone(),
        name,
        extension,
        file_type,
        size_bytes: md.size,
        modified: fmt_ts_required(md.modified),
        created: fmt_ts_opt(md.created),
        accessed: fmt_ts_opt(md.accessed),
        mode_octal: mode_to_octal(md.permissions),
        owner_user,
        owner_group,
        inode,
        hard_links,
        allocated_size_bytes,
        xattrs: md.xattrs.into_iter().collect(),
        has_acl: md.has_acl,
        security_context: (!md.security_context.is_empty()).then_some(md.security_context),
        mount_point: (!md.mount_point.is_empty()).then_some(md.mount_point),
        mount_source: (!md.mount_source.is_empty()).then_some(md.mount_source),
        filesystem: (!md.filesystem.is_empty()).then_some(md.filesystem),
        symlink_target,
        is_hidden,
        git_status,
        plugin,
    }
}

pub fn find_git_root(start: &Path) -> Option<PathBuf> {
    let mut dir = start;
    loop {
        if dir.join(".git").exists() {
            return Some(dir.to_path_buf());
        }
        dir = dir.parent()?;
    }
}

pub fn get_git_status_map(workspace_root: &Path) -> HashMap<String, String> {
    use std::process::Command;
    let mut status_map = HashMap::new();

    if let Ok(output) = Command::new("git")
        .args(["status", "--porcelain=v2", "--untracked-files=all"])
        .current_dir(workspace_root)
        .output()
    {
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.is_empty() {
                continue;
            }

            match parts[0] {
                "1" | "2" if parts.len() >= 9 => {
                    let xy = parts[1];
                    let path = parts[8];
                    status_map.insert(path.to_string(), xy.to_string());
                }
                "?" if parts.len() >= 2 => {
                    status_map.insert(parts[1].to_string(), "??".to_string());
                }
                "!" if parts.len() >= 2 => {
                    status_map.insert(parts[1].to_string(), "!!".to_string());
                }
                _ => {}
            }
        }
    }

    if let Ok(output) = std::process::Command::new("git")
        .args(["ls-files"])
        .current_dir(workspace_root)
        .output()
    {
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            status_map
                .entry(line.to_string())
                .or_insert_with(|| ".".to_string());
        }
    }

    status_map
}