Skip to main content

okf_studio/
worker.rs

1//! The background worker: every disk touch — loads, refactors, fixes, and
2//! structured frontmatter edits — happens here, never on the UI thread.
3//!
4//! The worker keeps the latest [`Snapshot`] it built and runs refactor
5//! previews and applies against that bundle. After every write it rebuilds,
6//! so the UI converges on the on-disk truth without the watcher's help.
7
8use 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
21/// The worker's fixed configuration.
22pub struct WorkerConfig {
23    /// Bundle root directory.
24    pub root: PathBuf,
25    /// Pinned evaluation date, when given.
26    pub today: Option<Date>,
27    /// Author identity for verification stamps and log entries.
28    pub author: String,
29}
30
31/// Spawns the worker thread and returns its command channel.
32#[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                // Re-run against the freshest bundle: if files changed since
81                // the preview, the apply still operates on disk truth.
82                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                // Re-run so the applied fix reflects the current disk state,
147                // then apply in one step.
148                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
276/// Appends a `{ by, at }` verification event to a concept's `verified` list
277/// via an order-preserving frontmatter round-trip, plus a log entry.
278fn 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
313/// Writes a new `stale_after` (normalized to an explicit-UTC datetime), plus
314/// a log entry.
315fn 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}