jsonl-tui 0.1.1

Terminal explorer for JSONL files: search, filter, sort, group and export from your keyboard or mouse.
//! View-configuration profiles: save/load via the `directories` crate, TOML format.
//!
//! Layout on disk (Linux example):
//!   ~/.config/jsonl-tui/profiles/<name>.toml   -- named, user-chosen profiles
//!   ~/.config/jsonl-tui/shapes/<hash>.toml     -- auto-applied per file "shape"
//!
//! The shape hash is a hash of the sorted set of discovered field paths, so
//! the same config auto-applies to files with the same structure.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

/// A saved view state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
    pub name: String,
    /// Active columns, in display order.
    pub columns: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sort_field: Option<String>,
    #[serde(default)]
    pub sort_desc: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub group_field: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub search: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub filter: Option<String>,
}

/// Wrapper so the TOML file reads as `[profile]` table.
#[derive(Serialize, Deserialize)]
struct ProfileFile {
    profile: Profile,
}

pub fn config_dir() -> Result<PathBuf> {
    let dirs = ProjectDirs::from("", "", "jsonl-tui")
        .context("could not resolve a per-user config directory")?;
    Ok(dirs.config_dir().to_path_buf())
}

pub fn profiles_dir() -> Result<PathBuf> {
    Ok(config_dir()?.join("profiles"))
}

pub fn shapes_dir() -> Result<PathBuf> {
    Ok(config_dir()?.join("shapes"))
}

/// Make a profile/shape name safe to use as a file stem.
fn sanitize(name: &str) -> String {
    let s: String = name
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if s.is_empty() {
        "default".to_string()
    } else {
        s
    }
}

/// Save a profile as `<dir>/<name>.toml`. Returns the written path.
pub fn save_profile_in(dir: &Path, profile: &Profile) -> Result<PathBuf> {
    fs::create_dir_all(dir)
        .with_context(|| format!("cannot create config dir '{}'", dir.display()))?;
    let path = dir.join(format!("{}.toml", sanitize(&profile.name)));
    let text = toml::to_string_pretty(&ProfileFile {
        profile: profile.clone(),
    })
    .context("failed to serialize profile")?;
    fs::write(&path, text).with_context(|| format!("cannot write '{}'", path.display()))?;
    Ok(path)
}

/// Load `<dir>/<name>.toml`.
pub fn load_profile_in(dir: &Path, name: &str) -> Result<Profile> {
    let path = dir.join(format!("{}.toml", sanitize(name)));
    let text = fs::read_to_string(&path)
        .with_context(|| format!("cannot read profile '{}'", path.display()))?;
    let file: ProfileFile = toml::from_str(&text)
        .with_context(|| format!("invalid profile file '{}'", path.display()))?;
    Ok(file.profile)
}

pub fn save_profile(profile: &Profile) -> Result<PathBuf> {
    save_profile_in(&profiles_dir()?, profile)
}

pub fn load_profile(name: &str) -> Result<Profile> {
    load_profile_in(&profiles_dir()?, name)
}

/// Save the shape-keyed config (auto-applied to files with the same fields).
pub fn save_shape_profile(shape_hash: &str, profile: &Profile) -> Result<PathBuf> {
    let mut p = profile.clone();
    p.name = shape_hash.to_string();
    save_profile_in(&shapes_dir()?, &p)
}

/// Load the shape-keyed config, if any.
pub fn load_shape_profile(shape_hash: &str) -> Result<Profile> {
    load_profile_in(&shapes_dir()?, shape_hash)
}

/// Names of all saved named profiles (sorted).
pub fn list_profiles() -> Vec<String> {
    let Ok(dir) = profiles_dir() else {
        return Vec::new();
    };
    let Ok(rd) = fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut names: Vec<String> = rd
        .filter_map(|e| {
            let p = e.ok()?.path();
            if p.extension()? == "toml" {
                Some(p.file_stem()?.to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();
    names.sort();
    names
}

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

    fn temp_dir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "jsonl-tui-test-{}-{}",
            tag,
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&dir);
        dir
    }

    #[test]
    fn profile_round_trip() {
        let dir = temp_dir("roundtrip");
        let profile = Profile {
            name: "my profile!".to_string(),
            columns: vec!["type".into(), "user.id".into(), "score".into()],
            sort_field: Some("score".into()),
            sort_desc: true,
            group_field: Some("type".into()),
            search: Some("re:err".into()),
            filter: Some("score>3".into()),
        };
        let path = save_profile_in(&dir, &profile).unwrap();
        assert!(path.exists());
        // name is sanitized in the filename but preserved in the payload
        assert_eq!(path.file_name().unwrap(), "my_profile_.toml");
        let loaded = load_profile_in(&dir, "my profile!").unwrap();
        assert_eq!(loaded, profile);
        fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn profile_round_trip_with_none_fields() {
        let dir = temp_dir("none-fields");
        let profile = Profile {
            name: "minimal".to_string(),
            columns: vec!["a".into()],
            sort_field: None,
            sort_desc: false,
            group_field: None,
            search: None,
            filter: None,
        };
        save_profile_in(&dir, &profile).unwrap();
        let loaded = load_profile_in(&dir, "minimal").unwrap();
        assert_eq!(loaded, profile);
        fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn load_missing_profile_is_error() {
        let dir = temp_dir("missing");
        assert!(load_profile_in(&dir, "nope").is_err());
    }

    #[test]
    fn profile_file_is_hand_editable_toml() {
        let dir = temp_dir("toml");
        let profile = Profile {
            name: "default".to_string(),
            columns: vec!["type".into(), "score".into()],
            sort_field: Some("score".into()),
            sort_desc: true,
            group_field: None,
            search: None,
            filter: None,
        };
        let path = save_profile_in(&dir, &profile).unwrap();
        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("[profile]"));
        assert!(text.contains("sort_desc = true"));
        assert!(text.contains("columns = "));
        fs::remove_dir_all(&dir).unwrap();
    }
}