1use crate::app::{Command, Msg, PreviewReport, RefactorOp};
9use crate::snapshot::Snapshot;
10use okf_core::log::append_log_entry;
11use okf_core::scaffold::current_iso_timestamp;
12use okf_core::{
13 ConceptOptions, Date, Document, FixOptions, MergeOptions, MoveOptions, RefactorError,
14 RemoveOptions, RenameSectionOptions, SplitOptions, Value, create_concept, merge_concepts,
15 move_concept, remediate_bundle, remove_concept, rename_section, split_concept, yaml::Mapping,
16};
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::mpsc::{Receiver, Sender, channel};
20
21pub struct WorkerConfig {
23 pub root: PathBuf,
25 pub today: Option<Date>,
27 pub author: String,
29}
30
31#[must_use]
33pub fn spawn(config: WorkerConfig, msg_tx: Sender<Msg>) -> Sender<Command> {
34 let (cmd_tx, cmd_rx) = channel::<Command>();
35 std::thread::spawn(move || run_worker(&config, &cmd_rx, &msg_tx));
36 cmd_tx
37}
38
39#[allow(clippy::too_many_lines)]
40fn run_worker(config: &WorkerConfig, cmd_rx: &Receiver<Command>, msg_tx: &Sender<Msg>) {
41 let mut generation: u64 = 0;
42 let mut today = config.today;
43 let mut snapshot: Option<Arc<Snapshot>> = None;
44
45 let reload =
46 |generation: &mut u64, today: Option<Date>, snapshot: &mut Option<Arc<Snapshot>>| -> bool {
47 *generation += 1;
48 match Snapshot::build(&config.root, today, *generation) {
49 Ok(snap) => {
50 let snap = Arc::new(snap);
51 *snapshot = Some(Arc::clone(&snap));
52 let _ = msg_tx.send(Msg::SnapshotReady(snap));
53 true
54 }
55 Err(e) => {
56 let _ = msg_tx.send(Msg::SnapshotFailed(e.to_string()));
57 false
58 }
59 }
60 };
61
62 while let Ok(command) = cmd_rx.recv() {
63 match command {
64 Command::Shutdown => break,
65 Command::Reload => {
66 reload(&mut generation, today, &mut snapshot);
67 }
68 Command::SetToday(date) => {
69 today = date;
70 reload(&mut generation, today, &mut snapshot);
71 }
72 Command::Preview { request, op } => {
73 let result = snapshot.as_ref().map_or_else(
74 || Err(RefactorError::Io("no snapshot loaded yet".into())),
75 |snap| run_refactor(snap, &op, true, &config.author),
76 );
77 let _ = msg_tx.send(Msg::PreviewReady(request, result));
78 }
79 Command::Apply(op) => {
80 reload(&mut generation, today, &mut snapshot);
83 let result = snapshot.as_ref().map_or_else(
84 || Err(RefactorError::Io("no snapshot loaded yet".into())),
85 |snap| run_refactor(snap, &op, false, &config.author),
86 );
87 let _ = msg_tx.send(Msg::Applied(
88 result
89 .map(|report| toast_for(&report))
90 .map_err(|e| e.to_string()),
91 ));
92 reload(&mut generation, today, &mut snapshot);
93 }
94 Command::StampVerification(id) => {
95 let result = stamp_verification(config, &id);
96 let _ = msg_tx.send(Msg::Applied(result));
97 reload(&mut generation, today, &mut snapshot);
98 }
99 Command::SetStaleAfter(id, date) => {
100 let result = set_stale_after(config, &id, date);
101 let _ = msg_tx.send(Msg::Applied(result));
102 reload(&mut generation, today, &mut snapshot);
103 }
104 Command::CreateConcept {
105 rel_path,
106 type_,
107 title,
108 } => {
109 let options = ConceptOptions {
110 type_,
111 title,
112 author: Some(config.author.clone()),
113 ..ConceptOptions::default()
114 };
115 let result = create_concept(config.root.join(&rel_path), &options)
116 .map(|path| format!("✔ created {}", path.display()))
117 .map_err(|e| e.to_string());
118 let _ = msg_tx.send(Msg::Applied(result));
119 reload(&mut generation, today, &mut snapshot);
120 }
121 Command::PreviewFix => match remediate_bundle(&config.root, &FixOptions::default()) {
122 Ok(report) => {
123 let _ = msg_tx.send(Msg::FixReportReady(Box::new(report)));
124 }
125 Err(e) => {
126 let _ = msg_tx.send(Msg::Error(e.to_string()));
127 }
128 },
129 Command::ApplyFixFile(path) => {
130 let result = okf_core::remediate_file(&path, &FixOptions::default())
131 .and_then(|report| {
132 if report.changed {
133 std::fs::write(&report.path, &report.remediated_content)?;
134 }
135 Ok(format!(
136 "✔ fixed {} issue(s) in {}",
137 report.remediations.len(),
138 report.path.display()
139 ))
140 })
141 .map_err(|e| e.to_string());
142 let _ = msg_tx.send(Msg::Applied(result));
143 reload(&mut generation, today, &mut snapshot);
144 }
145 Command::ApplyFix => {
146 let result = remediate_bundle(&config.root, &FixOptions::default())
149 .and_then(|report| {
150 let total = report.total_remediations();
151 let (files, _) = report.apply()?;
152 Ok(format!("✔ fixed {total} issue(s) in {files} file(s)"))
153 })
154 .map_err(|e| e.to_string());
155 let _ = msg_tx.send(Msg::Applied(result));
156 reload(&mut generation, today, &mut snapshot);
157 }
158 }
159 }
160}
161
162fn run_refactor(
163 snapshot: &Snapshot,
164 op: &RefactorOp,
165 dry_run: bool,
166 author: &str,
167) -> Result<PreviewReport, RefactorError> {
168 let bundle = &snapshot.bundle;
169 let author = Some(author.to_string());
170 match op {
171 RefactorOp::Move {
172 source,
173 target,
174 force,
175 } => move_concept(
176 bundle,
177 source,
178 target,
179 &MoveOptions {
180 dry_run,
181 force: *force,
182 author,
183 ..MoveOptions::default()
184 },
185 )
186 .map(PreviewReport::Move),
187 RefactorOp::Remove {
188 target,
189 redirect_to,
190 unlink,
191 force,
192 } => remove_concept(
193 bundle,
194 target,
195 &RemoveOptions {
196 dry_run,
197 force: *force,
198 redirect_to: redirect_to.clone(),
199 unlink: *unlink,
200 author,
201 ..RemoveOptions::default()
202 },
203 )
204 .map(PreviewReport::Remove),
205 RefactorOp::Merge { source, target } => merge_concepts(
206 bundle,
207 source,
208 target,
209 &MergeOptions {
210 dry_run,
211 author,
212 ..MergeOptions::default()
213 },
214 )
215 .map(PreviewReport::Merge),
216 RefactorOp::Split {
217 source,
218 target,
219 section,
220 title,
221 force,
222 } => split_concept(
223 bundle,
224 source,
225 target,
226 &SplitOptions {
227 section: section.clone(),
228 title: title.clone(),
229 force: *force,
230 dry_run,
231 author,
232 ..SplitOptions::default()
233 },
234 )
235 .map(PreviewReport::Split),
236 RefactorOp::RenameSection { concept, old, new } => rename_section(
237 bundle,
238 concept,
239 old,
240 new,
241 &RenameSectionOptions {
242 dry_run,
243 update_log: true,
244 author,
245 },
246 )
247 .map(PreviewReport::RenameSection),
248 }
249}
250
251fn toast_for(report: &PreviewReport) -> String {
252 match report {
253 PreviewReport::Move(r) => format!(
254 "✔ renamed {} → {} ({} files)",
255 r.source,
256 r.target,
257 r.affected_files.len()
258 ),
259 PreviewReport::Remove(r) => {
260 format!("✔ removed {} ({} files)", r.target, r.affected_files.len())
261 }
262 PreviewReport::Merge(r) => format!(
263 "✔ merged {} → {} ({} links)",
264 r.source, r.target, r.rewritten_links_count
265 ),
266 PreviewReport::Split(r) => {
267 format!("✔ split '{}' out of {} → {}", r.section, r.source, r.target)
268 }
269 PreviewReport::RenameSection(r) => format!(
270 "✔ renamed section '{}' → '{}' in {}",
271 r.old_section, r.new_section, r.concept
272 ),
273 }
274}
275
276fn stamp_verification(config: &WorkerConfig, id: &okf_core::ConceptId) -> Result<String, String> {
279 let path = id.to_path(&config.root);
280 let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
281 let mut doc = Document::parse(&text).map_err(|e| e.to_string())?;
282
283 let mut event = Mapping::new();
284 event.insert("by", Value::String(config.author.clone()));
285 event.insert("at", Value::String(current_iso_timestamp()));
286 let event = Value::Mapping(event);
287
288 let new_value = match doc.frontmatter.get("verified").cloned() {
289 Some(Value::Sequence(mut items)) => {
290 items.push(event);
291 Value::Sequence(items)
292 }
293 Some(existing @ Value::Mapping(_)) => Value::Sequence(vec![existing, event]),
294 _ => Value::Sequence(vec![event]),
295 };
296 doc.frontmatter.set("verified", new_value);
297 std::fs::write(&path, doc.serialize()).map_err(|e| e.to_string())?;
298
299 let today = config.today.or_else(Date::today_utc).unwrap_or(Date {
300 year: 2026,
301 month: 1,
302 day: 1,
303 });
304 let _ = append_log_entry(
305 &config.root,
306 today,
307 "Update",
308 &format!("Verified concept `{id}` (by {}).", config.author),
309 );
310 Ok(format!("✔ verified {id} (by {})", config.author))
311}
312
313fn set_stale_after(
316 config: &WorkerConfig,
317 id: &okf_core::ConceptId,
318 date: Date,
319) -> Result<String, String> {
320 let path = id.to_path(&config.root);
321 let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
322 let mut doc = Document::parse(&text).map_err(|e| e.to_string())?;
323 doc.frontmatter
324 .set("stale_after", Value::String(format!("{date}T00:00:00Z")));
325 std::fs::write(&path, doc.serialize()).map_err(|e| e.to_string())?;
326
327 let today = config.today.or_else(Date::today_utc).unwrap_or(Date {
328 year: 2026,
329 month: 1,
330 day: 1,
331 });
332 let _ = append_log_entry(
333 &config.root,
334 today,
335 "Update",
336 &format!(
337 "Extended `stale_after` of `{id}` to {date} (by {}).",
338 config.author
339 ),
340 );
341 Ok(format!("✔ {id} fresh until {date}"))
342}