use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
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>,
}
#[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"))
}
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
}
}
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)
}
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)
}
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)
}
pub fn load_shape_profile(shape_hash: &str) -> Result<Profile> {
load_profile_in(&shapes_dir()?, shape_hash)
}
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());
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();
}
}