mod notes;
mod rows;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use yamled::{Path, Segment, Spacing, Style};
pub struct Document {
yaml: yamled::Document,
}
#[derive(Clone, Debug)]
pub enum Change {
Set {
key: String,
value: serde_json::Value,
},
Unset { key: String },
List {
key: String,
before: Vec<String>,
after: Vec<String>,
},
}
impl Change {
pub fn set(key: &str, value: impl Into<serde_json::Value>) -> Self {
Self::Set {
key: key.to_owned(),
value: value.into(),
}
}
#[must_use]
pub fn unset(key: &str) -> Self {
Self::Unset {
key: key.to_owned(),
}
}
}
impl Document {
pub fn new(source: String) -> Result<Self> {
let yaml = yamled::Document::parse(source)
.context("this ledger is not YAML qctl can edit")?
.with_spacing(Spacing::Blank);
Ok(Self { yaml })
}
#[must_use]
pub fn into_source(self) -> String {
self.yaml.into_string()
}
pub fn reorder_sections(&mut self, order: &[&str]) -> Result<()> {
let wanted: Vec<&str> = order
.iter()
.copied()
.filter(|name| self.yaml.node(§ion(name)).is_some())
.collect();
let mut written: Vec<(usize, &str)> = wanted
.iter()
.filter_map(|name| Some((self.yaml.locate(§ion(name))?.start, *name)))
.collect();
written.sort_unstable();
if written
.iter()
.map(|(_, name)| *name)
.eq(wanted.iter().copied())
{
return Ok(());
}
let present: Vec<Segment> = wanted
.iter()
.map(|name| Segment::Key((*name).to_owned()))
.collect();
self.yaml
.reorder(&Path::root(), &present)
.context("reorder the lists")
}
pub fn set_indent(&mut self, name: &str, indent: usize) -> Result<()> {
let path = section(name);
let Some(list) = self.yaml.node(&path) else {
return Ok(());
};
if list.style() != Style::BlockSequence || list.is_empty() {
return Ok(());
}
let Some(first) = self.yaml.locate(&path.clone().index(0)) else {
return Ok(());
};
let source = self.yaml.as_str();
let line = &source[source[..first.start].rfind('\n').map_or(0, |at| at + 1)..];
if line.len() - line.trim_start().len() == indent {
return Ok(());
}
self.yaml
.reindent(&path, indent)
.with_context(|| format!("re-indent {name}"))
}
pub fn set<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) -> Result<()> {
self.yaml
.replace(§ion(key), value)
.with_context(|| format!("write {key}"))
}
#[must_use]
pub fn row_key_shape(&self, name: &str, index: usize, key: &str) -> Option<KeyShape> {
let node = self.yaml.node(§ion(name).index(index).key(key))?;
Some(match node.style() {
Style::BlockSequence | Style::FlowSequence => KeyShape::Sequence,
_ => KeyShape::Scalar,
})
}
pub fn remove_row_key(&mut self, name: &str, index: usize, key: &str) -> Result<()> {
self.yaml
.remove(§ion(name).index(index).key(key))
.with_context(|| format!("remove {key} from {name}[{index}]"))
}
pub fn replace_row_notes(&mut self, name: &str, index: usize, items: &[String]) -> Result<()> {
let path = section(name).index(index).key("notes");
self.yaml
.replace(&path, &NO_ITEMS)
.context("empty the notes")?;
self.push_notes(&path, items)
}
pub fn revise(&mut self, name: &str, index: usize, changes: &[Change]) -> Result<()> {
let row = section(name).index(index);
for change in changes {
match change {
Change::Set { key, value } => self.write_field(&row, key, value)?,
Change::Unset { key } => self.drop_field(&row, key)?,
Change::List { key, before, after } => {
self.write_list(&row, key, before, after)?;
}
}
}
Ok(())
}
fn write_field(&mut self, row: &Path, key: &str, value: &serde_json::Value) -> Result<()> {
let path = row.clone().key(key);
if self.yaml.node(&path).is_some() {
self.yaml.replace(&path, value)
} else {
self.yaml.insert(row, key, value, yamled::Position::End)
}
.with_context(|| format!("write {key}"))
}
fn drop_field(&mut self, row: &Path, key: &str) -> Result<()> {
let path = row.clone().key(key);
if self.yaml.node(&path).is_none() {
return Ok(());
}
self.yaml
.remove(&path)
.with_context(|| format!("remove {key}"))
}
fn write_list(
&mut self,
row: &Path,
key: &str,
before: &[String],
after: &[String],
) -> Result<()> {
if after.is_empty() {
return self.drop_field(row, key);
}
let path = row.clone().key(key);
let Some(list) = self.yaml.node(&path) else {
self.yaml
.insert(row, key, &NO_ITEMS, yamled::Position::End)
.with_context(|| format!("add {key}"))?;
return self.push_items(&path, key, after);
};
let appended = list.style() == Style::BlockSequence && after.starts_with(before);
if appended {
return self.push_items(&path, key, &after[before.len()..]);
}
if key == "notes" {
self.yaml
.replace(&path, &NO_ITEMS)
.context("empty the notes")?;
return self.push_notes(&path, after);
}
self.yaml
.replace(&path, after)
.with_context(|| format!("write {key}"))
}
fn push_items(&mut self, path: &Path, key: &str, items: &[String]) -> Result<()> {
if key == "notes" {
return self.push_notes(path, items);
}
self.tight(|yaml| {
items
.iter()
.try_for_each(|item| yaml.push(path, item))
.with_context(|| format!("add to {key}"))
})
}
fn push_notes(&mut self, path: &Path, items: &[String]) -> Result<()> {
self.tight(|yaml| {
items
.iter()
.try_for_each(|note| yaml.push_text(path, note, notes::style(note)))
.context("add a note")
})
}
fn tight<R>(&mut self, edit: impl FnOnce(&mut yamled::Document) -> R) -> R {
let mut yaml = self.yaml.clone().with_spacing(Spacing::Tight);
let result = edit(&mut yaml);
self.yaml = yaml.with_spacing(Spacing::Blank);
result
}
}
const NO_ITEMS: [&str; 0] = [];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KeyShape {
Scalar,
Sequence,
}
fn section(name: &str) -> Path {
Path::root().key(name)
}
pub fn must_still_parse(source: &str) -> Result<()> {
if ctl_core::input::Input::new("tasks.yaml", source)
.parse::<serde_json::Value>()
.is_err()
{
bail!("this edit would leave a file that is not valid YAML");
}
Ok(())
}