use anyhow::{bail, Context, Result};
use mimir_core::model::{now_unix, Kind, NewNode, Node};
use mimir_core::store::{self, row_to_node, NODE_COLS};
use mimir_core::Mimir;
use rusqlite::{params, Connection, OptionalExtension};
pub const RULES_TAG: &str = "mimir-rules";
fn find_rules(conn: &Connection, project_id: i64) -> Result<Option<Node>> {
let node = conn
.query_row(
&format!(
"SELECT {NODE_COLS} FROM node
WHERE kind = 'memory' AND project_id = ?1 AND deleted_at IS NULL
AND tags_text LIKE '%{RULES_TAG}%'
ORDER BY updated_at DESC LIMIT 1"
),
[project_id],
row_to_node,
)
.optional()?;
Ok(node)
}
fn project(mimir: &Mimir) -> Result<Node> {
mimir.project_for_cwd(&std::env::current_dir()?)?.context(
"not inside a project. The rules pack is per-project; it needs a \
project root (.git/.hg/.svn/.jj), or `touch .mimir` to mark one.",
)
}
pub fn set(text_parts: Vec<String>) -> Result<()> {
let mut mimir = Mimir::open()?;
let proj = project(&mimir)?;
let proj_name = proj.title.clone().unwrap_or_else(|| "project".into());
let text = if text_parts.is_empty() {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
text_parts.join(" ")
};
let text = text.trim().to_string();
if text.is_empty() {
bail!("empty rules text (pass it as arguments or pipe it on stdin)");
}
let title = format!("{proj_name} — project rules");
if let Some(existing) = find_rules(&mimir.conn, proj.id)? {
mimir.conn.execute(
"UPDATE node SET body = ?2, title = ?3, updated_at = ?4, pinned = 1 WHERE id = ?1",
params![existing.id, text, title, now_unix()],
)?;
println!("updated rules pack for {proj_name} ({} chars)", text.len());
} else {
let mut new = NewNode::new(Kind::Memory);
new.subkind = Some("note".into());
new.title = Some(title);
new.body = Some(text.clone());
new.project_id = Some(proj.id);
new.tags = vec![RULES_TAG.into()];
let node = store::insert_node(&mimir.conn, new)?;
store::set_pinned(&mimir.conn, node.id, true)?;
println!(
"stored rules pack for {proj_name} ({} chars) — pinned",
text.len()
);
}
if let Err(err) = mimir.embed_pending() {
tracing::warn!(%err, "embedding rules pack failed");
}
Ok(())
}
pub fn show() -> Result<()> {
let mimir = Mimir::open()?;
let Some(proj) = mimir.project_for_cwd(&std::env::current_dir()?)? else {
return Ok(());
};
match find_rules(&mimir.conn, proj.id)? {
Some(node) => {
if let Some(body) = node.body {
let name = proj.title.as_deref().unwrap_or("project");
println!("# {name} — project rules (via Mimir)\n\n{body}");
}
}
None => {
eprintln!("no rules pack for this project (set one with `mimir rules set <text>`)");
}
}
Ok(())
}
pub fn clear() -> Result<()> {
let mimir = Mimir::open()?;
let proj = project(&mimir)?;
match find_rules(&mimir.conn, proj.id)? {
Some(node) => {
store::soft_delete(&mimir.conn, node.id)?;
println!(
"cleared rules pack for {}",
proj.title.as_deref().unwrap_or("project")
);
}
None => println!("no rules pack to clear"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mimir_core::config::Paths;
fn mimir_in(dir: &std::path::Path) -> Mimir {
Mimir::open_at(Paths::under_root(dir)).unwrap()
}
#[test]
fn set_then_find_is_pinned_and_replaces() {
let dir = tempfile::tempdir().unwrap();
let mimir = mimir_in(dir.path());
let proj = store::ensure_project(&mimir.conn, "/tmp/p", "p").unwrap();
let mut new = NewNode::new(Kind::Memory);
new.subkind = Some("note".into());
new.title = Some("p — project rules".into());
new.body = Some("Always use tabs.".into());
new.project_id = Some(proj.id);
new.tags = vec![RULES_TAG.into()];
let node = store::insert_node(&mimir.conn, new).unwrap();
store::set_pinned(&mimir.conn, node.id, true).unwrap();
let found = find_rules(&mimir.conn, proj.id).unwrap().unwrap();
assert!(found.pinned);
assert_eq!(found.body.as_deref(), Some("Always use tabs."));
assert!(found.tags_text.contains(RULES_TAG));
}
#[test]
fn none_when_absent() {
let dir = tempfile::tempdir().unwrap();
let mimir = mimir_in(dir.path());
let proj = store::ensure_project(&mimir.conn, "/tmp/p", "p").unwrap();
assert!(find_rules(&mimir.conn, proj.id).unwrap().is_none());
}
}