specfold_core/authoring/
fix.rs1use super::plan::Plan;
5use crate::check::finding::{Finding, Fix};
6use crate::corpus::Corpus;
7use crate::markdown::frontmatter::edit;
8use crate::model::stamp;
9use crate::workspace::vcs;
10use anyhow::Result;
11use serde_json::Value;
12use std::collections::BTreeMap;
13
14#[derive(Debug, Clone, Default)]
15pub struct Pick {
16 pub id: String,
18 pub path: String,
19 pub line: usize,
20}
21
22fn replace_on_line(text: &str, line: usize, from: &str, to: &str) -> Option<String> {
23 let mut lines: Vec<String> = text.split_inclusive('\n').map(str::to_string).collect();
24 let l = lines.get_mut(line.checked_sub(1)?)?;
25 if !l.contains(from) {
26 return None;
27 }
28 *l = l.replace(from, to);
30 Some(lines.concat())
31}
32
33fn fill_frontmatter(c: &Corpus, idx: usize, text: &str) -> Result<String> {
35 let d = &c.docs[idx];
36 let initial = c.cfg.registry.get(&d.id.ty).map(|t| t.initial.clone()).unwrap_or_else(|| "draft".into());
37 let created = vcs::first_commit_stamp(&c.cfg.root, &d.path)
38 .and_then(|s| stamp::normalize_date(&s))
39 .unwrap_or_else(stamp::today);
40 let title = d.title.clone().unwrap_or_else(|| d.slug.replace('-', " "));
41 edit::rewrite(text, |m| {
42 m.entry("id").or_insert_with(|| Value::String(d.id.to_string()));
43 m.entry("title").or_insert_with(|| Value::String(title));
44 m.entry("status").or_insert_with(|| Value::String(initial));
45 m.entry("created").or_insert_with(|| Value::String(created));
46 m.entry("updated").or_insert_with(|| Value::String(stamp::now_value()));
47 let ordered = edit::ordered(std::mem::take(m));
48 *m = ordered;
49 })
50}
51
52fn drop_alias(c: &Corpus, texts: &mut BTreeMap<String, String>, alias: &str, keep: &str) -> Result<usize> {
55 let mut changed = 0;
56 for d in &c.docs {
57 if d.id.to_string() == keep || !d.list_field("aliases").iter().any(|a| a == alias) {
58 continue;
59 }
60 let Some(t) = texts.get(&d.path).cloned().or_else(|| std::fs::read_to_string(c.cfg.root.join(&d.path)).ok())
61 else {
62 continue;
63 };
64 let n = edit::rewrite(&t, |m| {
65 if let Some(Value::Array(list)) = m.get_mut("aliases") {
66 list.retain(|v| v.as_str() != Some(alias));
67 if list.is_empty() {
68 m.remove("aliases");
69 }
70 }
71 })?;
72 if n != t {
73 texts.insert(d.path.clone(), n);
74 changed += 1;
75 }
76 }
77 if let Some(file) = &c.aliases.file {
78 let t = texts.get(file).cloned().or_else(|| std::fs::read_to_string(c.cfg.root.join(file)).ok());
79 if let Some(t) = t {
80 let n: String = t
81 .split_inclusive('\n')
82 .filter(|l| match l.trim_end().split_once('\t') {
83 Some((old, new)) => old != alias || new.trim() == keep,
84 None => true,
85 })
86 .collect();
87 if n != t {
88 texts.insert(file.clone(), n);
89 changed += 1;
90 }
91 }
92 }
93 Ok(changed)
94}
95
96pub fn plan(c: &Corpus, findings: &[Finding], pick: Option<&Pick>) -> Result<(Plan, usize)> {
97 let mut texts: BTreeMap<String, String> = BTreeMap::new();
98 let mut applied = 0;
99 let read = |texts: &mut BTreeMap<String, String>, p: &str| -> Option<String> {
100 if let Some(t) = texts.get(p) {
101 return Some(t.clone());
102 }
103 std::fs::read_to_string(c.cfg.root.join(p)).ok()
104 };
105 for f in findings {
106 match (&f.fix, f.rule.as_str()) {
107 (Some(Fix::Replace { path, line, from, to }), _) => {
108 let Some(t) = read(&mut texts, path) else { continue };
109 let wiki = to.strip_suffix("]]").filter(|open| open.starts_with("[["));
111 let n = wiki.and_then(|open| replace_on_line(&t, *line, &format!("[[{from}"), open));
112 if let Some(n) = n.or_else(|| replace_on_line(&t, *line, from, to)) {
113 texts.insert(path.clone(), n);
114 applied += 1;
115 }
116 }
117 (_, "S1") if f.message.starts_with("missing") || f.message == "no frontmatter block" => {
118 let Some(idx) = c.docs.iter().position(|d| d.path == f.path) else { continue };
119 let Some(t) = read(&mut texts, &f.path) else { continue };
120 texts.insert(f.path.clone(), fill_frontmatter(c, idx, &t)?);
121 applied += 1;
122 }
123 (_, "L1") if !f.candidates.is_empty() => {
124 let Some(p) = pick.filter(|p| p.path == f.path && p.line == f.line && f.candidates.contains(&p.id))
125 else {
126 continue;
127 };
128 let Some(t) = read(&mut texts, &f.path) else { continue };
129 let Some(target) = f.message.split("]]").next().and_then(|s| s.strip_prefix("[[")) else { continue };
130 let target_base = target.split('#').next().unwrap_or(target);
131 if let Some(n) = replace_on_line(&t, f.line, &format!("[[{target_base}"), &format!("[[{}", p.id)) {
132 texts.insert(f.path.clone(), n);
133 applied += 1;
134 }
135 }
136 (_, "I2") if f.candidates.len() > 1 => {
137 let Some(p) = pick.filter(|p| p.path == f.path && f.candidates.contains(&p.id)) else { continue };
138 let Some(alias) = f.message.strip_prefix("alias '").and_then(|m| m.split_once("' names")).map(|x| x.0)
139 else {
140 continue;
141 };
142 applied += drop_alias(c, &mut texts, alias, &p.id)?;
143 }
144 _ => {}
145 }
146 }
147 let mut plan = Plan::default();
148 for (path, content) in texts {
149 plan.write(path, content, false);
150 }
151 Ok((plan, applied))
152}
153
154pub fn translation_pairs(c: &Corpus) -> Result<(Plan, usize)> {
157 let mut plan = Plan::default();
158 let mut n = 0;
159 for (i, d) in c.docs.iter().enumerate() {
160 if crate::translation::source_ref(c, i).is_some() {
161 continue;
162 }
163 let Some(inf) = crate::translation::inferred(c, i) else { continue };
164 let Some(src) = inf.source else { continue };
165 let Ok(t) = std::fs::read_to_string(c.cfg.root.join(&d.path)) else { continue };
166 let source = c.docs[src].id.to_string();
167 let new = edit::rewrite(&t, |m| {
168 m.entry("lang").or_insert_with(|| Value::String(inf.lang.clone()));
169 let rel = m.entry("rel").or_insert_with(|| Value::Object(Default::default()));
170 if let Value::Object(r) = rel {
171 r.insert(crate::translation::RELATION.into(), Value::Array(vec![Value::String(source.clone())]));
172 }
173 let ordered = edit::ordered(std::mem::take(m));
174 *m = ordered;
175 })?;
176 if new != t {
177 plan.write(d.path.clone(), new, false);
178 n += 1;
179 }
180 }
181 Ok((plan, n))
182}