use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::cli::args::FindFilters;
use crate::output::{CommandOutcome, Format, format_error};
const TOML_FILENAME: &str = ".hyalo.toml";
fn resolve_toml_path(dir: &Path) -> PathBuf {
dir.join(TOML_FILENAME)
}
pub(crate) fn load_views(dir: &Path) -> HashMap<String, FindFilters> {
let toml_path = resolve_toml_path(dir);
let contents = match fs::read_to_string(&toml_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
Err(e) => {
crate::warn::warn(format!("could not read .hyalo.toml for views: {e}"));
return HashMap::new();
}
};
let table: toml::Table = match toml::from_str(&contents) {
Ok(t) => t,
Err(e) => {
crate::warn::warn(format!("malformed .hyalo.toml: {e}"));
return HashMap::new();
}
};
let Some(toml::Value::Table(views_table)) = table.get("views") else {
return HashMap::new();
};
let mut views = HashMap::new();
for (name, value) in views_table {
match value.clone().try_into::<FindFilters>() {
Ok(filters) => {
views.insert(name.clone(), filters);
}
Err(e) => {
crate::warn::warn(format!("skipping malformed view '{name}': {e}"));
}
}
}
views
}
pub(crate) fn list_views(dir: &Path, _format: Format) -> Result<CommandOutcome> {
let views = load_views(dir);
let mut items: Vec<serde_json::Value> = Vec::new();
let mut sorted_keys: Vec<&String> = views.keys().collect();
sorted_keys.sort();
for name in sorted_keys {
let filters = &views[name];
let filters_json =
serde_json::to_value(filters).context("failed to serialize view filters")?;
items.push(serde_json::json!({
"name": name,
"filters": filters_json,
}));
}
let total = items.len() as u64;
let output = serde_json::to_string_pretty(&items).context("failed to serialize views list")?;
Ok(CommandOutcome::success_with_total(output, total))
}
pub(crate) fn set_view(
dir: &Path,
name: &str,
filters: &FindFilters,
format: Format,
) -> Result<CommandOutcome> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Ok(CommandOutcome::UserError(format_error(
format,
&format!(
"invalid view name '{name}': must be non-empty and contain only alphanumeric characters, hyphens, or underscores"
),
None,
None,
None,
)));
}
let filters_value =
toml::Value::try_from(filters).context("failed to serialize filters to TOML")?;
let default_value = toml::Value::try_from(FindFilters::default())
.context("failed to serialize default filters")?;
if filters_value == default_value {
return Ok(CommandOutcome::UserError(format_error(
format,
"no filters specified — a view must contain at least one filter",
None,
None,
None,
)));
}
let toml_path = resolve_toml_path(dir);
let mut doc = read_toml_doc(&toml_path)?;
if !doc.contains_key("views") {
doc["views"] = toml_edit::Item::Table(toml_edit::Table::new());
}
let Some(views_item) = doc.get_mut("views") else {
unreachable!()
};
let Some(views_table) = views_item.as_table_mut() else {
return Ok(CommandOutcome::UserError(format_error(
format,
"'views' in .hyalo.toml is not a table — check your config file",
None,
None,
None,
)));
};
let edit_item = toml_value_to_edit_item(&filters_value)?;
views_table.insert(name, edit_item);
write_toml_doc(&toml_path, &doc)?;
let output = serde_json::to_string_pretty(&serde_json::json!({
"action": "set",
"name": name,
}))
.context("failed to serialize result")?;
Ok(CommandOutcome::success(output))
}
pub(crate) fn remove_view(dir: &Path, name: &str, format: Format) -> Result<CommandOutcome> {
let toml_path = resolve_toml_path(dir);
let mut doc = read_toml_doc(&toml_path)?;
let Some(views_table) = doc.get_mut("views").and_then(|v| v.as_table_mut()) else {
return Ok(CommandOutcome::UserError(format_error(
format,
&format!("view '{name}' not found"),
None,
Some("run 'hyalo views list' to see available views"),
None,
)));
};
if views_table.remove(name).is_none() {
return Ok(CommandOutcome::UserError(format_error(
format,
&format!("view '{name}' not found"),
None,
Some("run 'hyalo views list' to see available views"),
None,
)));
}
if views_table.is_empty() {
doc.remove("views");
}
write_toml_doc(&toml_path, &doc)?;
let output = serde_json::to_string_pretty(&serde_json::json!({
"action": "removed",
"name": name,
}))
.context("failed to serialize result")?;
Ok(CommandOutcome::success(output))
}
fn read_toml_doc(toml_path: &Path) -> Result<toml_edit::DocumentMut> {
match fs::read_to_string(toml_path) {
Ok(contents) => contents
.parse::<toml_edit::DocumentMut>()
.context("failed to parse .hyalo.toml"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(toml_edit::DocumentMut::new()),
Err(e) => Err(e).context("failed to read .hyalo.toml"),
}
}
fn write_toml_doc(toml_path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
fs::write(toml_path, doc.to_string()).context("failed to write .hyalo.toml")
}
fn toml_value_to_edit_item(value: &toml::Value) -> Result<toml_edit::Item> {
let text = toml::to_string(value).context("failed to serialize TOML value")?;
let doc: toml_edit::DocumentMut = text
.parse()
.context("failed to re-parse serialized TOML value")?;
Ok(toml_edit::Item::Table(doc.into_table()))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tag_filters(tag: &str) -> FindFilters {
FindFilters {
tag: vec![tag.to_owned()],
..Default::default()
}
}
#[test]
fn set_view_writes_to_custom_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let filters = make_tag_filters("iteration");
let outcome = set_view(dir, "my-view", &filters, Format::Json).unwrap();
assert!(matches!(outcome, CommandOutcome::Success { .. }));
let toml_path = dir.join(".hyalo.toml");
assert!(toml_path.exists(), ".hyalo.toml not found in custom dir");
let contents = std::fs::read_to_string(&toml_path).unwrap();
assert!(
contents.contains("my-view"),
"view 'my-view' not found in written TOML"
);
}
#[test]
fn load_views_reads_from_custom_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let filters = make_tag_filters("iteration");
set_view(dir, "iter-view", &filters, Format::Json).unwrap();
let views = load_views(dir);
assert!(
views.contains_key("iter-view"),
"expected view not found after load"
);
}
#[test]
fn remove_view_reads_from_custom_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let filters = make_tag_filters("done");
set_view(dir, "done-view", &filters, Format::Json).unwrap();
let outcome = remove_view(dir, "done-view", Format::Json).unwrap();
assert!(matches!(outcome, CommandOutcome::Success { .. }));
let views = load_views(dir);
assert!(
!views.contains_key("done-view"),
"view should be gone after remove"
);
}
#[test]
fn load_views_supports_orphan_and_dead_end_flags() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join(".hyalo.toml"),
"[views.orphans]\norphan = true\n\n[views.dead-ends]\ndead_end = true\n",
)
.unwrap();
let views = load_views(dir);
let orphan_view = views.get("orphans").expect("orphans view missing");
assert!(orphan_view.orphan, "view should have orphan = true");
assert!(
!orphan_view.dead_end,
"view should not have dead_end = true"
);
let dead_view = views.get("dead-ends").expect("dead-ends view missing");
assert!(dead_view.dead_end, "view should have dead_end = true");
}
#[test]
fn set_view_preserves_existing_sections_and_order() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let original = "# Main config\ndir = \"notes\"\nformat = \"text\"\n\n\
[search]\nlanguage = \"english\"\n\n\
[schema.types.iteration]\nrequired = [\"title\", \"date\"]\n";
std::fs::write(dir.join(".hyalo.toml"), original).unwrap();
let filters = make_tag_filters("iteration");
set_view(dir, "iter", &filters, Format::Json).unwrap();
let result = std::fs::read_to_string(dir.join(".hyalo.toml")).unwrap();
let dir_pos = result.find("dir =").unwrap();
let search_pos = result.find("[search]").unwrap();
let schema_pos = result.find("[schema").unwrap();
assert!(
dir_pos < search_pos && search_pos < schema_pos,
"existing section order should be preserved"
);
assert!(result.contains("# Main config"));
assert!(result.contains("[views.iter]"));
}
}