rigger 0.23.0

One seat for all your projects and tasks: a local record of what is done, what is next and when it ships - read by you and your coding assistant
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! A project's screen: what it is, where it stands, what it has written.
//!
//! The hub's README was the surface a person read to remember a project -
//! and it is generated from the record, lives in a notes vault, and is
//! opened in another application. Everything on it the record already
//! holds, so it can be printed where the work happens instead.
//!
//! This is not the context packet. The packet is written for an assistant
//! and spends its budget on recent events, because that is what a session
//! has to catch up on. A person opening a project wants the opposite: what
//! the thing is, what state it is in, what has been written down about it,
//! and the one line saying what is next.

use std::fmt::Write;

use anyhow::Result;
use serde::Serialize;

use crate::db::{Asked, Db, DocLine, Project, Task};
use crate::link;

#[derive(Debug, Serialize)]
pub struct Screen {
    pub name: String,
    pub about: Option<String>,
    pub path: String,
    pub remote: Option<String>,
    pub tier: Option<String>,
    pub rhythm_weeks: Option<u32>,
    pub gate: Option<String>,
    pub on_session_end: Option<String>,
    pub hub_path: Option<String>,
    pub last_shipped: Option<(String, String)>,
    /// How far the newest shipped version got past its tag, when anyone has
    /// said: the release engine, or GitHub as `sync` read it.
    pub delivery: Option<crate::delivery::Delivery>,
    pub versions_planned: u64,
    pub tasks_open: u64,
    pub commits_since_tag: Option<u32>,
    pub stage: Option<Stage>,
    pub documents: Vec<DocLine>,
    pub questions: usize,
    pub wishes: usize,
    /// What this project is tied to, read from its own side.
    pub links: Vec<link::Link>,
    /// The open wishes a neighbour asked for.
    pub asked: Vec<Asked>,
    pub next_step: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct Stage {
    pub version: String,
    pub title: Option<String>,
    pub tasks: Vec<Task>,
}

pub fn build(db: &Db, project: &Project, about: Option<String>) -> Result<Screen> {
    let activity = db.activity(project.id)?;
    let stage = db.current_stage(project.id)?.map(|s| Stage {
        version: s.version,
        title: s.title,
        tasks: s.tasks,
    });
    Ok(Screen {
        name: project.name.clone(),
        about,
        path: project.path.clone(),
        remote: project.remote.clone(),
        tier: project.tier.clone(),
        rhythm_weeks: project.rhythm_weeks,
        gate: project.gate.clone(),
        on_session_end: project.on_session_end.clone(),
        hub_path: project.hub_path.clone(),
        last_shipped: db.last_shipped_version(project.id)?,
        delivery: db.latest_delivery(project.id)?.and_then(|d| d.delivery),
        versions_planned: db.count_versions(project.id, "planned")?,
        tasks_open: db.count_open_tasks(project.id)?,
        commits_since_tag: activity.as_ref().map(|a| a.commits_since_tag),
        stage,
        documents: db.document_lines(project.id)?,
        questions: db.open_events(project.id, "question")?.len(),
        wishes: db.open_events(project.id, "wish")?.len(),
        links: db.links_of(project.id)?,
        asked: db.asked_wishes(project.id)?,
        next_step: db.latest_event_body(project.id, "next")?,
    })
}

pub fn render(screen: &Screen) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "{}", screen.name);
    if let Some(about) = &screen.about {
        let _ = writeln!(out, "{about}");
    }
    out.push('\n');

    let _ = writeln!(out, "{:<10} {}", "path", screen.path);
    if let Some(remote) = &screen.remote {
        let _ = writeln!(out, "{:<10} {remote}", "remote");
    }
    if let Some(hub) = &screen.hub_path {
        let _ = writeln!(out, "{:<10} {hub}", "hub");
    }
    if let Some(tier) = &screen.tier {
        let rhythm = match screen.rhythm_weeks {
            Some(1) => " · a version a week".to_string(),
            Some(n) => format!(" · a version every {n} weeks"),
            None => String::new(),
        };
        let _ = writeln!(out, "{:<10} {tier}{rhythm}", "tier");
    }
    if let Some(gate) = &screen.gate {
        let _ = writeln!(out, "{:<10} {gate}", "gate");
    }
    if let Some(hook) = &screen.on_session_end {
        let _ = writeln!(out, "{:<10} {hook}", "on end");
    }

    out.push('\n');
    match &screen.last_shipped {
        Some((version, day)) => {
            let _ = write!(out, "Last shipped {version} on {day}");
            if let Some(delivery) = screen.delivery {
                let _ = write!(out, " - {}", delivery.describe());
            }
            match screen.commits_since_tag {
                Some(0) | None => out.push('\n'),
                Some(1) => out.push_str(", 1 commit since\n"),
                Some(n) => {
                    let _ = writeln!(out, ", {n} commits since");
                }
            }
        }
        None => out.push_str("Nothing shipped yet\n"),
    }
    let _ = writeln!(out, "{} versions planned, {} tasks open", screen.versions_planned, screen.tasks_open);
    if screen.questions > 0 || screen.wishes > 0 {
        let _ = writeln!(out, "{} waiting on you, {} wishes unsorted", screen.questions, screen.wishes);
    }

    if let Some(stage) = &screen.stage {
        out.push('\n');
        let _ = write!(out, "Current stage: {}", stage.version);
        if let Some(title) = &stage.title {
            let _ = write!(out, " · {title}");
        }
        out.push('\n');
        for task in &stage.tasks {
            // The first line of a task, because a task of these plans is a
            // paragraph with the point in its opening clause.
            let first = task.title.lines().next().unwrap_or("").trim();
            let _ = writeln!(out, "  {} {}", mark(&task.status), trim_to(first, 96));
        }
    }

    let neighbours = render_neighbours(screen);
    if !neighbours.is_empty() {
        out.push('\n');
        out.push_str(&neighbours);
    }

    if !screen.documents.is_empty() {
        out.push('\n');
        out.push_str("Written down:\n");
        for doc in &screen.documents {
            let _ = writeln!(
                out,
                "  {:<9} {:<34} {}  rigger doc show {} {}",
                doc.kind,
                trim_to(&doc.title, 34),
                day(&doc.updated_at),
                screen.name,
                doc.slug
            );
        }
    }

    if let Some(next) = &screen.next_step {
        out.push('\n');
        out.push_str("Next step\n");
        let _ = writeln!(out, "  {}", trim_to(next.lines().next().unwrap_or("").trim(), 96));
    }
    out
}

/// Who this project stands beside, and who is waiting on it.
///
/// This is the half of a project's README that used to be prose - "kasl and
/// kasl-server are two halves of one thing", "the HTTPS door rhapsod built
/// is what hilvan needs" - written once in each of two hubs and true in
/// neither after a month. Printed from the links instead, so it is as
/// current as the last thing recorded.
///
/// The three kinds get three headings because they are three different
/// facts. Folding them into one list of neighbours would say that lyrid
/// stands beside dowel the way kasl stands beside kasl-server, and the
/// whole point of the distinction is that it does not.
fn render_neighbours(screen: &Screen) -> String {
    let mut out = String::new();
    let heading = |out: &mut String, text: &str| {
        let _ = writeln!(out, "{text}:");
    };

    for (kind, title) in [
        (link::Kind::Pair, "Paired with"),
        (link::Kind::Consumer, "Draws on"),
        (link::Kind::Donor, "Drawn on by"),
    ] {
        let of_kind: Vec<&link::Link> = screen.links.iter().filter(|l| l.kind == kind).collect();
        if of_kind.is_empty() {
            continue;
        }
        heading(&mut out, title);
        for found in of_kind {
            let far = match &found.far.version {
                Some(version) => format!("{} {version}", found.far.project),
                None => found.far.project.clone(),
            };
            // The near version only when there is one: the anchor is what
            // the pair was agreed at, and a blank there reads as a missing
            // version rather than as a tie that has none.
            let near = match &found.near.version {
                Some(version) => format!(" (here: {version})"),
                None => String::new(),
            };
            let note = found.note.as_deref().map(|n| format!("{n}")).unwrap_or_default();
            let _ = writeln!(out, "  {far}{near}{note}");
        }
    }

    if !screen.asked.is_empty() {
        heading(&mut out, "Neighbours are asking for");
        for wish in &screen.asked {
            let first = wish.body.lines().next().unwrap_or("").trim();
            let _ = writeln!(out, "  [{}] {}{}", wish.id, trim_to(first, 72), wish.asked_by);
        }
    }
    out
}

/// A task's status as one character, so the list reads as a column.
fn mark(status: &str) -> char {
    match status {
        "done" => 'x',
        "dropped" => '-',
        "active" => '>',
        "frozen" | "waiting-handoff" => '~',
        _ => ' ',
    }
}

/// The day of a timestamp, whichever of the two forms it is in.
fn day(stamp: &str) -> &str {
    stamp.split('T').next().unwrap_or(stamp)
}

fn trim_to(text: &str, limit: usize) -> String {
    if text.chars().count() <= limit {
        return text.to_string();
    }
    let cut: String = text.chars().take(limit.saturating_sub(1)).collect();
    format!("{}", cut.trim_end())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn screen() -> Screen {
        Screen {
            name: "sample".into(),
            about: Some("A sample product".into()),
            path: "C:\\dev\\sample".into(),
            remote: None,
            tier: Some("C".into()),
            rhythm_weeks: Some(1),
            gate: Some("cargo test".into()),
            on_session_end: None,
            hub_path: None,
            last_shipped: Some(("v0.2.0".into(), "2026-09-10".into())),
            delivery: None,
            versions_planned: 3,
            tasks_open: 4,
            commits_since_tag: Some(2),
            stage: None,
            documents: Vec::new(),
            questions: 0,
            wishes: 0,
            links: Vec::new(),
            asked: Vec::new(),
            next_step: None,
        }
    }

    #[test]
    fn the_screen_opens_with_what_the_project_is() {
        let text = render(&screen());
        assert!(text.starts_with("sample\nA sample product\n"), "{text}");
        assert!(text.contains("Last shipped v0.2.0 on 2026-09-10, 2 commits since"), "{text}");
        assert!(text.contains("cargo test"), "the gate is on the screen: {text}");
    }

    #[test]
    fn a_project_that_has_shipped_nothing_says_so_rather_than_nothing() {
        let mut s = screen();
        s.last_shipped = None;
        s.commits_since_tag = None;
        let text = render(&s);
        assert!(text.contains("Nothing shipped yet"), "{text}");
    }

    #[test]
    fn one_commit_since_the_tag_is_not_pluralised() {
        let mut s = screen();
        s.commits_since_tag = Some(1);
        assert!(render(&s).contains("1 commit since"), "{}", render(&s));
        // And a tag with nothing after it says nothing at all, rather than
        // "0 commits since", which reads as news when it is the opposite.
        s.commits_since_tag = Some(0);
        assert!(!render(&s).contains("commit"), "{}", render(&s));
    }

    #[test]
    fn a_document_carries_the_command_that_opens_it() {
        let mut s = screen();
        s.documents = vec![DocLine {
            kind: "vision".into(),
            slug: "vision".into(),
            title: "Vision of sample".into(),
            updated_at: "2026-09-12T08:00:00Z".into(),
        }];
        let text = render(&s);
        assert!(text.contains("rigger doc show sample vision"), "{text}");
        // The day, not the timestamp: a screen is read, not parsed.
        assert!(text.contains("2026-09-12") && !text.contains("08:00:00"), "{text}");
    }

    fn link_of(kind: link::Kind, far: &str, far_version: Option<&str>, near_version: Option<&str>) -> link::Link {
        link::Link {
            id: 1,
            kind,
            near: link::End {
                project: "sample".into(),
                version: near_version.map(String::from),
                shipped: near_version.map(|_| false),
            },
            far: link::End {
                project: far.into(),
                version: far_version.map(String::from),
                shipped: far_version.map(|_| false),
            },
            note: None,
        }
    }

    /// The three kinds are three facts, so they get three headings: a
    /// project that draws on another does not stand beside it.
    #[test]
    fn the_neighbours_are_told_apart_by_what_the_tie_is() {
        let mut s = screen();
        s.links = vec![
            link_of(link::Kind::Pair, "sample-server", Some("v0.3.0"), Some("v0.9.0")),
            link_of(link::Kind::Consumer, "widgets", None, None),
            link_of(link::Kind::Donor, "downstream", None, None),
        ];
        let text = render(&s);
        assert!(text.contains("Paired with:\n  sample-server v0.3.0 (here: v0.9.0)"), "{text}");
        assert!(text.contains("Draws on:\n  widgets\n"), "{text}");
        assert!(text.contains("Drawn on by:\n  downstream\n"), "{text}");
    }

    /// A project tied to nothing prints no heading at all, rather than an
    /// empty one that reads as a thing gone missing.
    #[test]
    fn a_project_with_no_ties_says_nothing_about_them() {
        let text = render(&screen());
        assert!(!text.contains("Paired with"), "{text}");
        assert!(!text.contains("Neighbours are asking"), "{text}");
    }

    /// A neighbour's wish carries the id that resolves it and the name of
    /// who asked: an order with no sender is one nobody can answer.
    #[test]
    fn a_wish_from_a_neighbour_names_the_neighbour_and_its_id() {
        let mut s = screen();
        s.asked = vec![Asked {
            id: 42,
            body: "A gate for the docs address".into(),
            asked_by: "lyrn".into(),
        }];
        let text = render(&s);
        assert!(text.contains("Neighbours are asking for:"), "{text}");
        assert!(text.contains("[42] A gate for the docs address — lyrn"), "{text}");
    }

    #[test]
    fn a_long_title_is_cut_and_says_it_was() {
        let long = "я".repeat(200);
        assert_eq!(trim_to(&long, 34).chars().count(), 34);
        assert!(trim_to(&long, 34).ends_with(''));
    }
}