use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub const CHARTER_CHAR_BUDGET: usize = 2000;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CharterLine {
pub id: String,
pub text: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Charter {
lines: Vec<CharterLine>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCharter {
#[serde(default, rename = "line")]
line: Vec<CharterLine>,
}
impl Charter {
pub fn default_path() -> Result<PathBuf> {
Ok(crate::work::mecha_home()?.join("charter.toml"))
}
pub fn load(path: &Path) -> Result<Charter> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Charter::default()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
Charter::parse(&text).with_context(|| format!("parsing {}", path.display()))
}
pub fn parse(text: &str) -> Result<Charter> {
let raw: RawCharter = toml::from_str(text)?;
Charter::validate(raw.line)
}
fn validate(lines: Vec<CharterLine>) -> Result<Charter> {
let mut seen = BTreeSet::new();
for line in &lines {
if line.id.trim().is_empty() {
bail!("a charter line has an empty `id`");
}
if line.text.trim().is_empty() {
bail!("charter line `{}` has empty `text`", line.id);
}
if !seen.insert(line.id.trim()) {
bail!(
"charter line id `{}` is used more than once — a goal reference \
naming it would not know which line it meant",
line.id
);
}
}
let lines = lines
.into_iter()
.map(|l| CharterLine {
id: l.id.trim().to_string(),
..l
})
.collect();
Ok(Charter { lines })
}
pub fn lines(&self) -> &[CharterLine] {
&self.lines
}
pub fn char_count(&self) -> usize {
prompt_block(self).map_or(0, |b| b.chars().count())
}
pub fn over_budget(&self) -> bool {
self.char_count() > CHARTER_CHAR_BUDGET
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
}
pub const TEMPLATE: &str = "\
# Your charter: standing priorities, in your own words, ranked highest
# first — ORDER IS RANK. There is no priority field; when two lines
# conflict, the higher one wins outright, and re-ranking is moving a line.
#
# mecha only ever reads this file. Each entry is:
#
# [[line]]
# id = \"a-short-stable-slug\" # unique; goal references point at it
# text = \"The priority itself, one or two sentences.\"
#
# One authoring trap, from the design doc: a line shaped like \"never
# disappoint anyone\" produces sycophancy and withheld bad news. Point it
# the other way — e.g.:
#
# [[line]]
# id = \"tell-the-truth-early\"
# text = \"Tell me the truth early, especially when it disappoints.\"
";
pub fn prompt_block(charter: &Charter) -> Option<String> {
if charter.is_empty() {
return None;
}
let mut out = String::from(
"## Charter\n\n\
Standing priorities the owner has written for you, ranked highest first \
and listed in that order. They are not weighted: when two conflict, the \
higher one wins outright, whatever the lower one would otherwise argue \
for — no amount of urgency on a lower line outranks a higher one.\n\n",
);
for (i, line) in charter.lines().iter().enumerate() {
out.push_str(&format!("{}. `{}` — {}\n", i + 1, line.id, line.text));
}
Some(out.trim_end().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_carries_no_active_lines() {
for l in TEMPLATE.lines() {
let l = l.trim();
assert!(
l.is_empty() || l.starts_with('#'),
"template has an uncommented line: {l:?}"
);
}
let c = Charter::parse(TEMPLATE).unwrap();
assert!(c.is_empty());
}
fn line(id: &str, text: &str) -> CharterLine {
CharterLine {
id: id.to_string(),
text: text.to_string(),
}
}
fn write_and_load(raw: &str) -> Result<Charter> {
let dir = std::env::temp_dir().join(format!(
"mecha-charter-test-{}-{:?}-{:?}",
std::process::id(),
std::thread::current().id(),
std::time::Instant::now()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("charter.toml");
std::fs::write(&path, raw).unwrap();
Charter::load(&path)
}
#[test]
fn the_standard_shape_parses_in_file_order() {
let raw = r#"
[[line]]
id = "protect-the-owner"
text = "Protect the owner's interests above all else."
[[line]]
id = "tell-the-truth-early"
text = "Tell the owner the truth early, especially when it disappoints."
"#;
let charter = write_and_load(raw).unwrap();
assert_eq!(
charter.lines(),
&[
line(
"protect-the-owner",
"Protect the owner's interests above all else."
),
line(
"tell-the-truth-early",
"Tell the owner the truth early, especially when it disappoints."
),
]
);
}
#[test]
fn a_missing_file_is_an_empty_charter_not_an_error() {
let path = std::env::temp_dir().join("mecha-charter-does-not-exist.toml");
let _ = std::fs::remove_file(&path);
let charter = Charter::load(&path).unwrap();
assert!(charter.is_empty());
}
#[test]
fn a_typo_d_table_name_is_a_load_error_not_a_silently_short_charter() {
let raw = r#"
[[line]]
id = "protect-the-owner"
text = "Protect the owner's interests above all else."
[[lines]]
id = "tell-the-truth-early"
text = "Tell the owner the truth early, especially when it disappoints."
"#;
let e = write_and_load(raw).unwrap_err().to_string();
assert!(e.contains("parsing"), "{e}");
}
#[test]
fn a_stray_priority_field_on_a_line_is_a_load_error() {
let raw = r#"
[[line]]
id = "a"
text = "one"
priority = 1
"#;
assert!(write_and_load(raw).is_err());
}
#[test]
fn a_duplicate_id_is_refused_because_a_reference_to_it_would_be_ambiguous() {
let e = Charter::validate(vec![line("a", "one"), line("a", "two")])
.unwrap_err()
.to_string();
assert!(e.contains("used more than once"), "{e}");
}
#[test]
fn ids_differing_only_by_surrounding_whitespace_still_collide() {
let e = Charter::validate(vec![line("a", "one"), line("a ", "two")])
.unwrap_err()
.to_string();
assert!(e.contains("used more than once"), "{e}");
}
#[test]
fn a_surviving_id_is_stored_trimmed_not_just_checked_trimmed() {
let charter = Charter::validate(vec![line(" x ", "one")]).unwrap();
assert_eq!(charter.lines()[0].id, "x");
}
#[test]
fn char_count_is_the_rendered_costs_not_just_the_authored_text() {
let charter = Charter::validate(vec![line("a", "short")]).unwrap();
assert_eq!(
charter.char_count(),
prompt_block(&charter).unwrap().chars().count()
);
assert!(charter.char_count() > "a".len() + "short".len());
}
#[test]
fn an_empty_id_or_text_is_refused() {
assert!(Charter::validate(vec![line("", "text")]).is_err());
assert!(Charter::validate(vec![line("id", " ")]).is_err());
}
#[test]
fn a_charter_over_the_character_budget_still_loads_and_says_so() {
let long = "x".repeat(CHARTER_CHAR_BUDGET + 1);
let charter = Charter::validate(vec![line("only-line", &long)]).unwrap();
assert_eq!(charter.lines().len(), 1);
assert!(charter.over_budget());
}
#[test]
fn a_charter_under_the_budget_is_not_over_it() {
let charter = Charter::validate(vec![line("a", "short")]).unwrap();
assert!(!charter.over_budget());
}
#[test]
fn an_empty_charter_contributes_no_block_at_all() {
assert_eq!(prompt_block(&Charter::default()), None);
}
#[test]
fn the_block_lists_lines_in_file_order_not_sorted() {
let charter = Charter {
lines: vec![
line("b-line", "second priority"),
line("a-line", "first priority"),
],
};
let block = prompt_block(&charter).unwrap();
let b = block.find("b-line").unwrap();
let a = block.find("a-line").unwrap();
assert!(b < a, "{block}");
}
#[test]
fn the_block_explains_the_ordering_is_load_bearing() {
let charter = Charter {
lines: vec![line("only", "the only priority")],
};
let block = prompt_block(&charter).unwrap();
assert!(block.contains("not weighted"), "{block}");
}
const WEB_EDITOR_SAMPLE: &str = r#"# What mecha is for, most important first.
#
# Order is rank.
[[line]]
id = "say-no-early"
text = "A refusal on Monday is a kindness."
[[line]]
id = "quote-and-break"
text = "She said \"no\" early.\nAnd meant it."
"#;
#[test]
fn the_web_editors_serialisation_is_what_this_reader_loads() {
let charter = Charter::parse(WEB_EDITOR_SAMPLE).unwrap();
let ids: Vec<&str> = charter.lines().iter().map(|l| l.id.as_str()).collect();
assert_eq!(
ids,
["say-no-early", "quote-and-break"],
"file order is rank"
);
assert_eq!(
charter.lines()[1].text,
"She said \"no\" early.\nAnd meant it.",
"the editor's escaping must survive the reader"
);
}
}