[refactor] streamline crate structure, update dependencies, and integrate CLI functionalities

This commit is contained in:
2025-08-13 14:05:13 +02:00
parent 128db0f733
commit 5c64677e79
17 changed files with 812 additions and 1235 deletions

View File

@@ -1,149 +1,108 @@
// SPDX-License-Identifier: MIT
// Simple ConfigService with XDG/system/workspace merge and atomic writes
use anyhow::{Context, Result};
use directories::BaseDirs;
use crate::prelude::*;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, path::PathBuf};
/// Generic configuration represented as TOML table
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config(pub toml::value::Table);
const ENV_PREFIX: &str = "POLYSCRIBE";
impl Config {
/// Get a mutable reference to a top-level table under the given key, creating
/// an empty table if it does not exist yet.
pub fn get_table_mut(&mut self, key: &str) -> &mut toml::value::Table {
let needs_init = !matches!(self.0.get(key), Some(toml::Value::Table(_)));
if needs_init {
self.0.insert(key.to_string(), toml::Value::Table(Default::default()));
}
match self.0.get_mut(key) {
Some(toml::Value::Table(t)) => t,
_ => unreachable!(),
/// Configuration for the Polyscribe application
///
/// Contains paths to models and plugins directories that can be customized
/// through configuration files or environment variables.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Directory path where ML models are stored
pub models_dir: Option<PathBuf>,
/// Directory path where plugins are stored
pub plugins_dir: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
models_dir: None,
plugins_dir: None,
}
}
}
fn merge_tables(base: &mut toml::value::Table, overlay: &toml::value::Table) {
for (k, v) in overlay.iter() {
match (base.get_mut(k), v) {
(Some(toml::Value::Table(bsub)), toml::Value::Table(osub)) => {
merge_tables(bsub, osub);
}
_ => {
base.insert(k.clone(), v.clone());
}
}
}
}
fn read_toml(path: &Path) -> Result<toml::value::Table> {
let s = fs::read_to_string(path).with_context(|| format!("Failed to read config: {}", path.display()))?;
let v: toml::Value = toml::from_str(&s).with_context(|| format!("Invalid TOML in {}", path.display()))?;
Ok(v.as_table().cloned().unwrap_or_default())
}
fn write_toml_atomic(path: &Path, tbl: &toml::value::Table) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("Failed to create config dir: {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
let mut f = fs::File::create(&tmp).with_context(|| format!("Failed to create temp file: {}", tmp.display()))?;
let s = toml::to_string_pretty(&toml::Value::Table(tbl.clone()))?;
f.write_all(s.as_bytes())?;
if !s.ends_with('\n') { f.write_all(b"\n")?; }
drop(f);
fs::rename(&tmp, path).with_context(|| format!("Failed to atomically replace config: {}", path.display()))?;
Ok(())
}
fn system_config_path() -> PathBuf {
if cfg!(unix) { PathBuf::from("/etc").join("polyscribe").join("config.toml") } else { default_user_config_path() }
}
fn default_user_config_path() -> PathBuf {
if let Some(base) = BaseDirs::new() {
return PathBuf::from(base.config_dir()).join("polyscribe").join("config.toml");
}
PathBuf::from(".polyscribe").join("config.toml")
}
fn workspace_config_path() -> PathBuf {
PathBuf::from(".polyscribe").join("config.toml")
}
/// Service responsible for loading and saving PolyScribe configuration
#[derive(Debug, Default, Clone)]
/// Service for managing Polyscribe configuration
///
/// Provides functionality to load, save, and access configuration settings
/// from disk or environment variables.
pub struct ConfigService;
impl ConfigService {
/// Load configuration, merging system < user < workspace < env overrides.
pub fn load(&self) -> Result<Config> {
let mut accum = toml::value::Table::default();
let sys = system_config_path();
if sys.exists() {
merge_tables(&mut accum, &read_toml(&sys)?);
}
let user = default_user_config_path();
if user.exists() {
merge_tables(&mut accum, &read_toml(&user)?);
}
let ws = workspace_config_path();
if ws.exists() {
merge_tables(&mut accum, &read_toml(&ws)?);
}
// Env overrides: POLYSCRIBE__SECTION__KEY=value
let mut env_over = toml::value::Table::default();
for (k, v) in env::vars() {
if let Some(rest) = k.strip_prefix("POLYSCRIBE__") {
let parts: Vec<&str> = rest.split("__").collect();
if parts.is_empty() { continue; }
let val: toml::Value = toml::Value::String(v);
// Build nested tables
let mut current = &mut env_over;
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
current.insert(part.to_lowercase(), val.clone());
} else {
current = current.entry(part.to_lowercase()).or_insert_with(|| toml::Value::Table(Default::default()))
.as_table_mut().expect("table");
}
}
}
}
merge_tables(&mut accum, &env_over);
Ok(Config(accum))
/// Loads configuration from disk or returns default values if not found
///
/// This function attempts to read the configuration file from disk. If the file
/// doesn't exist or can't be parsed, it falls back to default values.
/// Environment variable overrides are then applied to the configuration.
pub fn load_or_default() -> Result<Config> {
let mut cfg = Self::read_disk().unwrap_or_default();
Self::apply_env_overrides(&mut cfg)?;
Ok(cfg)
}
/// Ensure user config exists with sensible defaults, return loaded config
pub fn ensure_user_config(&self) -> Result<Config> {
let path = default_user_config_path();
if !path.exists() {
let mut defaults = toml::value::Table::default();
defaults.insert("ui".into(), toml::Value::Table({
let mut t = toml::value::Table::default();
t.insert("theme".into(), toml::Value::String("auto".into()));
t
}));
write_toml_atomic(&path, &defaults)?;
/// Saves the configuration to disk
///
/// This function serializes the configuration to TOML format and writes it
/// to the standard configuration directory for the application.
/// Returns an error if writing fails or if project directories cannot be determined.
pub fn save(cfg: &Config) -> Result<()> {
let Some(dirs) = Self::dirs() else {
return Err(Error::Other("unable to get project dirs".into()));
};
let cfg_dir = dirs.config_dir();
fs::create_dir_all(cfg_dir)?;
let path = cfg_dir.join("config.toml");
let s = toml::to_string_pretty(cfg)?;
fs::write(path, s)?;
Ok(())
}
fn read_disk() -> Option<Config> {
let dirs = Self::dirs()?;
let path = dirs.config_dir().join("config.toml");
let s = fs::read_to_string(path).ok()?;
toml::from_str(&s).ok()
}
fn apply_env_overrides(cfg: &mut Config) -> Result<()> {
// POLYSCRIBE__SECTION__KEY format reserved for future nested config.
if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_MODELS_DIR")) {
cfg.models_dir = Some(PathBuf::from(v));
}
self.load()
if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_PLUGINS_DIR")) {
cfg.plugins_dir = Some(PathBuf::from(v));
}
Ok(())
}
/// Save to user config atomically, merging over existing user file.
pub fn save_user(&self, new_values: &toml::value::Table) -> Result<()> {
let path = default_user_config_path();
let mut base = if path.exists() { read_toml(&path)? } else { Default::default() };
merge_tables(&mut base, new_values);
write_toml_atomic(&path, &base)
/// Returns the standard project directories for the application
///
/// This function creates a ProjectDirs instance with the appropriate
/// organization and application names for Polyscribe.
/// Returns None if the project directories cannot be determined.
pub fn dirs() -> Option<ProjectDirs> {
ProjectDirs::from("dev", "polyscribe", "polyscribe")
}
/// Paths used for debugging/information
pub fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
(system_config_path(), default_user_config_path(), workspace_config_path())
/// Returns the default directory path for storing ML models
///
/// This function determines the standard data directory for the application
/// and appends a 'models' subdirectory to it.
/// Returns None if the project directories cannot be determined.
pub fn default_models_dir() -> Option<PathBuf> {
Self::dirs().map(|d| d.data_dir().join("models"))
}
/// Returns the default directory path for storing plugins
///
/// This function determines the standard data directory for the application
/// and appends a 'plugins' subdirectory to it.
/// Returns None if the project directories cannot be determined.
pub fn default_plugins_dir() -> Option<PathBuf> {
Self::dirs().map(|d| d.data_dir().join("plugins"))
}
}