use crate::config::{get_config_dir, get_config_path};
use crate::error::{QmdError, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
pub type ContextMap = BTreeMap<String, String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Collection {
pub path: String,
pub pattern: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ContextMap>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CollectionConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub global_context: Option<String>,
#[serde(default)]
pub collections: BTreeMap<String, Collection>,
}
#[derive(Debug, Clone)]
pub struct NamedCollection {
pub name: String,
pub path: String,
pub pattern: String,
pub context: Option<ContextMap>,
pub update: Option<String>,
}
impl From<(String, Collection)> for NamedCollection {
fn from((name, coll): (String, Collection)) -> Self {
Self {
name,
path: coll.path,
pattern: coll.pattern,
context: coll.context,
update: coll.update,
}
}
}
static INDEX_NAME: std::sync::RwLock<String> = std::sync::RwLock::new(String::new());
pub fn set_config_index_name(name: &str) {
if let Ok(mut index_name) = INDEX_NAME.write() {
*index_name = name.to_string();
}
}
fn get_index_name() -> String {
INDEX_NAME.read().map_or_else(
|_| "index".to_string(),
|s| {
if s.is_empty() {
"index".to_string()
} else {
s.clone()
}
},
)
}
fn ensure_config_dir() -> Result<()> {
if let Some(config_dir) = get_config_dir() {
fs::create_dir_all(&config_dir)?;
}
Ok(())
}
pub fn load_config() -> Result<CollectionConfig> {
let index_name = get_index_name();
let config_path = get_config_path(&index_name)
.ok_or_else(|| QmdError::Config("Could not determine config path".to_string()))?;
if !config_path.exists() {
return Ok(CollectionConfig::default());
}
let content = fs::read_to_string(&config_path)?;
let config: CollectionConfig = serde_yaml::from_str(&content)?;
Ok(config)
}
pub fn save_config(config: &CollectionConfig) -> Result<()> {
ensure_config_dir()?;
let index_name = get_index_name();
let config_path = get_config_path(&index_name)
.ok_or_else(|| QmdError::Config("Could not determine config path".to_string()))?;
let yaml = serde_yaml::to_string(config)?;
fs::write(&config_path, yaml)?;
Ok(())
}
pub fn get_collection(name: &str) -> Result<Option<NamedCollection>> {
let config = load_config()?;
Ok(config
.collections
.get(name)
.cloned()
.map(|c| NamedCollection::from((name.to_string(), c))))
}
pub fn list_collections() -> Result<Vec<NamedCollection>> {
let config = load_config()?;
Ok(config
.collections
.into_iter()
.map(NamedCollection::from)
.collect())
}
pub fn add_collection(name: &str, path: &str, pattern: &str) -> Result<()> {
let mut config = load_config()?;
let existing_context = config.collections.get(name).and_then(|c| c.context.clone());
config.collections.insert(
name.to_string(),
Collection {
path: path.to_string(),
pattern: pattern.to_string(),
context: existing_context,
update: None,
},
);
save_config(&config)
}
pub fn remove_collection(name: &str) -> Result<bool> {
let mut config = load_config()?;
let removed = config.collections.remove(name).is_some();
if removed {
save_config(&config)?;
}
Ok(removed)
}
pub fn rename_collection(old_name: &str, new_name: &str) -> Result<bool> {
let mut config = load_config()?;
let Some(collection) = config.collections.remove(old_name) else {
return Ok(false);
};
if config.collections.contains_key(new_name) {
return Err(QmdError::Config(format!(
"Collection '{new_name}' already exists"
)));
}
config.collections.insert(new_name.to_string(), collection);
save_config(&config)?;
Ok(true)
}
pub fn get_global_context() -> Result<Option<String>> {
let config = load_config()?;
Ok(config.global_context)
}
pub fn set_global_context(context: Option<&str>) -> Result<()> {
let mut config = load_config()?;
config.global_context = context.map(str::to_string);
save_config(&config)
}
pub fn add_context(collection_name: &str, path_prefix: &str, context_text: &str) -> Result<bool> {
let mut config = load_config()?;
let Some(collection) = config.collections.get_mut(collection_name) else {
return Ok(false);
};
let context_map = collection.context.get_or_insert_with(BTreeMap::new);
context_map.insert(path_prefix.to_string(), context_text.to_string());
save_config(&config)?;
Ok(true)
}
pub fn remove_context(collection_name: &str, path_prefix: &str) -> Result<bool> {
let mut config = load_config()?;
let Some(collection) = config.collections.get_mut(collection_name) else {
return Ok(false);
};
let Some(ref mut context_map) = collection.context else {
return Ok(false);
};
let removed = context_map.remove(path_prefix).is_some();
if context_map.is_empty() {
collection.context = None;
}
if removed {
save_config(&config)?;
}
Ok(removed)
}
#[derive(Debug, Clone)]
pub struct ContextEntry {
pub collection: String,
pub path: String,
pub context: String,
}
pub fn list_all_contexts() -> Result<Vec<ContextEntry>> {
let config = load_config()?;
let mut results = Vec::new();
if let Some(ref global) = config.global_context {
results.push(ContextEntry {
collection: "*".to_string(),
path: "/".to_string(),
context: global.clone(),
});
}
for (name, collection) in &config.collections {
if let Some(ref context_map) = collection.context {
for (path, context) in context_map {
results.push(ContextEntry {
collection: name.clone(),
path: path.clone(),
context: context.clone(),
});
}
}
}
Ok(results)
}
pub fn find_context_for_path(collection_name: &str, file_path: &str) -> Result<Option<String>> {
let config = load_config()?;
let Some(collection) = config.collections.get(collection_name) else {
return Ok(config.global_context);
};
let Some(ref context_map) = collection.context else {
return Ok(config.global_context);
};
let mut matches: Vec<(&str, &str)> = Vec::new();
for (prefix, context) in context_map {
let normalized_path = if file_path.starts_with('/') {
file_path.to_string()
} else {
format!("/{file_path}")
};
let normalized_prefix = if prefix.starts_with('/') {
prefix.clone()
} else {
format!("/{prefix}")
};
if normalized_path.starts_with(&normalized_prefix) {
matches.push((prefix.as_str(), context.as_str()));
}
}
if !matches.is_empty() {
matches.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
return Ok(Some(matches[0].1.to_string()));
}
Ok(config.global_context)
}
#[must_use]
pub fn get_config_file_path() -> Option<std::path::PathBuf> {
let index_name = get_index_name();
get_config_path(&index_name)
}
#[must_use]
pub fn config_exists() -> bool {
get_config_file_path().is_some_and(|p| p.exists())
}
#[must_use]
pub fn is_valid_collection_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}