use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::db::{DOC_KINDS, SINGULAR_DOC_KINDS};
pub const EDITOR_ENV: &str = "RIGGER_EDITOR";
pub const LINE_EDITOR: &str = "scheda";
pub const LINE_EDITOR_ARGS: [&str; 1] = ["--wait"];
pub struct Editor {
pub program: String,
pub args: Vec<String>,
pub source: &'static str,
}
pub fn editor() -> Editor {
for env in [EDITOR_ENV, "VISUAL", "EDITOR"] {
if let Ok(raw) = std::env::var(env) {
let raw = raw.trim();
if !raw.is_empty() {
let mut parts = raw.split_whitespace().map(str::to_string);
let program = parts.next().unwrap_or_default();
return Editor {
program,
args: parts.collect(),
source: if env == EDITOR_ENV { "RIGGER_EDITOR" } else { "the environment" },
};
}
}
}
if is_installed(LINE_EDITOR) {
return Editor {
program: LINE_EDITOR.to_string(),
args: LINE_EDITOR_ARGS.iter().map(|a| a.to_string()).collect(),
source: "scheda, which is installed",
};
}
Editor {
program: fallback_editor().to_string(),
args: Vec::new(),
source: "the system default",
}
}
#[cfg(windows)]
fn fallback_editor() -> &'static str {
"notepad"
}
#[cfg(not(windows))]
fn fallback_editor() -> &'static str {
"vi"
}
fn is_installed(program: &str) -> bool {
let resolved = crate::open::resolve(program);
if Path::new(&resolved).is_file() {
return true;
}
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| dir.join(program).is_file())
}
pub fn edit_file(path: &Path) -> Result<()> {
let chosen = editor();
let program = crate::open::resolve(&chosen.program);
let mut command = crate::open::launcher(&program);
for arg in &chosen.args {
command.arg(arg);
}
command.arg(path);
let status = command
.status()
.with_context(|| format!("cannot start {} ({})", chosen.program, chosen.source))?;
if !status.success() {
bail!("{} closed with {status}; the document is unchanged", chosen.program);
}
Ok(())
}
pub fn scratch_dir() -> PathBuf {
std::env::temp_dir().join(format!("rigger-{}", std::process::id()))
}
pub fn scratch_path(project: &str, slug: &str) -> PathBuf {
scratch_dir().join(format!("{project}-{slug}.md"))
}
pub fn check_kind(kind: &str) -> Result<()> {
if !DOC_KINDS.contains(&kind) {
bail!("'{kind}' is not a kind of document; rigger knows {}", DOC_KINDS.join(", "));
}
Ok(())
}
pub fn is_singular(kind: &str) -> bool {
SINGULAR_DOC_KINDS.contains(&kind)
}
pub fn template_paths(kind: &str) -> Result<Vec<PathBuf>> {
let file = format!("doc.{kind}.md");
Ok(vec![crate::profile::current_dir()?.join(&file), crate::paths::data_dir()?.join(&file)])
}
pub fn template(kind: &str, title: &str) -> String {
match template_file(kind) {
Some(text) if text.contains(TITLE_PLACEHOLDER) => text.replace(TITLE_PLACEHOLDER, title),
Some(text) => format!("# {title}\n\n{}", text.trim_start_matches('\n')),
None => built_in_template(kind, title),
}
}
pub const TITLE_PLACEHOLDER: &str = "{{title}}";
fn template_file(kind: &str) -> Option<String> {
for path in template_paths(kind).ok()? {
if let Ok(text) = std::fs::read_to_string(&path)
&& !text.trim().is_empty()
{
return Some(text);
}
}
None
}
fn built_in_template(kind: &str, title: &str) -> String {
let sections: &[&str] = match kind {
"vision" => &["## Why", "## The idea", "## What it is made of", "## Boundaries", "## What success looks like"],
"research" => &["## The question", "## What was found", "## What was decided"],
"rituals" => &["## What this is", "## Rules of the project", "## Where things are", "## Running it"],
"decisions" => &["## How this journal is kept"],
_ => &[],
};
let mut out = format!("# {title}\n");
for section in sections {
out.push_str(&format!("\n{section}\n\n\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_owners_choice_wins_over_everything() {
assert!(is_singular("vision"));
assert!(is_singular("rituals"));
assert!(!is_singular("research"), "a project has many research notes");
}
#[test]
fn a_new_document_starts_from_the_questions_its_kind_exists_to_answer() {
let vision = built_in_template("vision", "Vision of rigger");
assert!(vision.starts_with("# Vision of rigger\n"));
assert!(vision.contains("## Why"));
assert!(vision.contains("## What success looks like"));
assert_eq!(built_in_template("other", "Notes"), "# Notes\n");
}
#[test]
fn a_kind_outside_the_vocabulary_is_refused_with_the_list() {
let e = check_kind("plan").unwrap_err().to_string();
assert!(e.contains("vision"), "the error must name what rigger knows: {e}");
}
}