tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! The link/unlink pair (WP04).
//!
//! A pair is **two ends and a stated order**, which is why it is a type and not a
//! list: an operation with two ends and no order cannot be applied without one of
//! them being invented, and inventing one is a policy nobody chose. The direction,
//! both selectors and the kind all arrive on [`crate::bulk::RelationshipOp`] and
//! this module never infers any of them.
//!
//! ## Both writes are the store's, not this module's
//!
//! `Store::link` and `Store::unlink` already own everything that is subtle here:
//! the canonical edge, the inverse spelling, back-references across both files,
//! idempotence, one History event per accepted mutation, and atomic per-file
//! writes. A bulk pair therefore resolves its endpoints, **delegates**, and reports
//! — it does not write an endpoint itself, and `tests/bulk_pair.rs` asserts that
//! the file a `tix link` would have written is the file a bulk pair writes.
//!
//! The contract's wording is the same: "delegate to the shared link/unlink write
//! semantics … do not flatten a pair into a single endpoint write." Flattening is
//! the specific failure this module exists to prevent: an operation with two ends
//! that writes one of them.

use crate::bulk::plan::PairPlan;
use crate::bulk::{IssuePatch, RelationshipOp};
use crate::error::{Result, TixError};
use crate::relationships::{CanonicalEdge, RelationKind};
use crate::storage::Store;

/// What a pair resolution found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairResolution {
    /// The canonical edge, when both endpoints resolved and the pair is not
    /// self-referential.
    pub edge: Option<CanonicalEdge>,
    /// The pair as typed, whether or not it resolves.
    pub requested: RequestedPair,
    /// Each endpoint's number, when **that** endpoint resolved.
    ///
    /// Per endpoint rather than one `edge`, because a missing `to` still leaves a
    /// resolved `from` and a plan that said `from: Err` would be reporting a failure
    /// that did not happen. A first version put the error on `from` unconditionally
    //  and the WP02 case that checks "the `from` resolved" caught it.
    pub from_number: Option<u32>,
    pub to_number: Option<u32>,
    /// Why it does not apply, if it does not.
    pub diagnostic: Option<PairDiagnostic>,
}

/// The pair exactly as the user typed it, unresolved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestedPair {
    pub link: bool,
    pub from: String,
    pub to: String,
    pub kind: RelationKind,
}

impl From<&RelationshipOp> for RequestedPair {
    fn from(op: &RelationshipOp) -> Self {
        Self {
            link: op.link,
            from: op.from.clone(),
            to: op.to.clone(),
            kind: op.kind,
        }
    }
}

impl RequestedPair {
    /// The operation's own verb, so a message says "link" or "unlink" rather than
    /// "a relationship operation".
    pub fn verb(&self) -> &'static str {
        if self.link { "link" } else { "unlink" }
    }
}

/// Why one pair cannot be applied.
///
/// A **stable** vocabulary, because T019 asks for a stable per-pair diagnostic and
/// a message that varies with the wording of a selector is not stable. Each variant
/// maps to exactly one situation, and the situation is named rather than embedded
/// in prose.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairProblem {
    /// A selector matched no issue.
    Missing,
    /// A selector matched more than one issue.
    Ambiguous,
    /// Both selectors name the same issue.
    SelfLink,
    /// The store refused it: a duplicate, or a cycle the graph closes.
    Refused,
}

impl PairProblem {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Missing => "missing_target",
            Self::Ambiguous => "ambiguous_target",
            Self::SelfLink => "self_link",
            Self::Refused => "refused",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairDiagnostic {
    pub problem: PairProblem,
    /// Which endpoint: `"from"` or `"to"`, or `""` when the problem is the pair
    /// rather than an end.
    pub endpoint: &'static str,
    pub message: String,
}

impl std::fmt::Display for PairDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.endpoint.is_empty() {
            write!(f, "{}: {}", self.problem.as_str(), self.message)
        } else {
            write!(
                f,
                "{} on {}: {}",
                self.problem.as_str(),
                self.endpoint,
                self.message
            )
        }
    }
}

/// Resolve one pair against the store, without writing anything.
///
/// The three refusals are **this module's** and the fourth is the store's:
///
/// - `missing_target` and `ambiguous_target` come from the selector, and are the
///   **existing** diagnostics — `parse_selector` is what produces them, and this
///   module does not reword them. A bulk pair that invented its own wording for an
///   ambiguous fragment would be a second diagnostic for one situation.
/// - `self_link` is `1 -> 1`: a cycle of length one, refused here rather than
///   handed to the graph, because the graph's answer for it is a cycle error and a
///   plan wants to say "self" rather than "cycle".
/// - `refused` is whatever the store says — a duplicate, or a cycle the edge
///   closes. The store's judgement is the judgement.
pub fn resolve_pair(store: &Store, op: &RelationshipOp) -> PairResolution {
    let requested = RequestedPair::from(op);
    let resolve = |raw: &str, endpoint: &'static str| -> (Option<u32>, Option<PairDiagnostic>) {
        match crate::commands::parse_selector(store, raw) {
            Err(e) => {
                // Ambiguity and "no such issue" are both selector failures, and both
                // arrive as an `Err` — so the question is which one, and the answer
                // has to be something **stable**. The error *variant* is not enough
                // on its own (`InvalidInput` could be anything), so this matches
                // `commands::AMBIGUITY_PREFIX`, which is the marker the selector
                // itself uses and which `commands::mod.rs:465` already matches on
                // for the same purpose.
                //
                // Matching on the prose instead — `"ambiguous".contains(...)` — is
                // what a first draft of this comment considered, and it is the thing
                // T019's "stable" forbids: a reworded message silently becomes a
                // different diagnosis. The message is still carried, for a human.
                let problem = match &e {
                    TixError::InvalidInput(m)
                        if m.starts_with(crate::commands::AMBIGUITY_PREFIX) =>
                    {
                        PairProblem::Ambiguous
                    }
                    _ => PairProblem::Missing,
                };
                (
                    None,
                    Some(PairDiagnostic {
                        problem,
                        endpoint,
                        message: e.to_string(),
                    }),
                )
            }
            Ok(selector) => {
                // The store's own list is what makes *ambiguity* observable: a
                // selector that `parse_selector` accepts is already unique, so the
                // ambiguity has to be measured here, against the same corpus.
                match store.get(selector) {
                    Ok(issue) => (Some(issue.number), None),
                    Err(e) => (
                        None,
                        Some(PairDiagnostic {
                            problem: PairProblem::Missing,
                            endpoint,
                            message: e.to_string(),
                        }),
                    ),
                }
            }
        }
    };

    let (from, from_problem) = resolve(&op.from, "from");
    let (to, to_problem) = resolve(&op.to, "to");
    let problem = from_problem.or(to_problem);

    if let Some(diagnostic) = problem {
        return PairResolution {
            edge: None,
            from_number: from,
            to_number: to,
            requested,
            diagnostic: Some(diagnostic),
        };
    }
    let (from, to) = (from.expect("checked"), to.expect("checked"));

    if from == to {
        return PairResolution {
            edge: None,
            from_number: Some(from),
            to_number: Some(to),
            requested,
            diagnostic: Some(PairDiagnostic {
                problem: PairProblem::SelfLink,
                endpoint: "",
                message: format!(
                    "#{} {} itself — a relationship needs two issues",
                    from,
                    op.kind.as_str()
                ),
            }),
        };
    }

    let edge = CanonicalEdge::new(from, to, op.kind);
    PairResolution {
        edge: Some(edge),
        from_number: Some(from),
        to_number: Some(to),
        requested,
        diagnostic: None,
    }
}

/// Resolve against a store, as a [`PairPlan`] the plan builder can consume.
///
/// The shape the plan wants: a plan reports a pair, and WP02's `PairPlan` is what a
/// surface renders. This adapter exists so `build_plan` stays free of a store and
/// the closure is the only place the two meet.
pub fn plan_for(store: &Store, op: &RelationshipOp) -> PairPlan {
    let resolution = resolve_pair(store, op);
    // Each endpoint gets its own answer: the one that failed reports the diagnostic,
    // the one that succeeded reports its number. A self-link resolved **both** and
    // failed on the pair, so both report `Ok` and only `diagnostic` is set — which
    // is what distinguishes it from a missing endpoint without a fourth variant.
    let end = |number: Option<u32>, endpoint: &str, diagnostic: &Option<PairDiagnostic>| match (
        number,
        diagnostic.as_ref().map(|d| d.endpoint),
    ) {
        (Some(n), _) => Ok(n),
        (None, Some(failed)) if failed == endpoint => {
            Err(diagnostic.as_ref().expect("checked").message.clone())
        }
        _ => Err(String::new()),
    };
    match &resolution.diagnostic {
        Some(diagnostic) => PairPlan::refused(
            op.link,
            end(resolution.from_number, "from", &resolution.diagnostic),
            end(resolution.to_number, "to", &resolution.diagnostic),
            diagnostic.clone(),
        ),
        None => PairPlan::resolved(op.link, resolution.edge.expect("checked")),
    }
}

/// What applying a pair did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PairOutcome {
    /// The edge was written. Both endpoints are named, because a pair is two ends
    /// and a report that names one of them is a flattened write with extra steps.
    Applied {
        from: u32,
        to: u32,
        identity: String,
        /// The files the store wrote, in the store's own order. More than one is
        /// normal: `unlink` removes a redundant pair of rows from two files.
        files: Vec<String>,
    },
    /// The edge was already present (a link) or already absent (an unlink).
    /// Nothing was written and no History event was appended.
    NoOp { identity: String },
    /// Refused. Neither endpoint was touched.
    Refused(PairDiagnostic),
}

impl PairOutcome {
    pub fn as_line(&self) -> String {
        match self {
            Self::Applied {
                from,
                to,
                identity,
                files,
            } => format!(
                "{} #{from} -> #{to} ({identity}); wrote {}",
                if files.len() == 1 {
                    "linked"
                } else {
                    "unlinked"
                },
                files.join(", ")
            ),
            Self::NoOp { identity } => {
                format!("{identity} was already in that state; nothing written")
            }
            Self::Refused(diagnostic) => format!("refused: {diagnostic}"),
        }
    }

    /// The shared status word (`NFR-003`).
    ///
    /// The same three words `ItemOutcome::status` uses, from the same vocabulary, so a
    /// surface reporting a batch of pairs and a surface reporting a batch of field
    /// edits produce comparable per-item statuses. A pair has no "failed" variant —
    /// a write that fails comes back as an `Err` from `apply_pair` — so the three
    /// words are the three it can be, and a fourth would be a status no pair can
    /// reach.
    pub fn status(&self) -> &'static str {
        match self {
            Self::Applied { .. } => "applied",
            Self::NoOp { .. } => "no-op",
            Self::Refused(_) => "rejected",
        }
    }

    /// The files the outcome wrote, or none.
    ///
    /// A pair writes **two** files, so there is no single filename and the projection
    /// a surface needs is a list. Returning a list rather than a joined string is what
    /// lets the TUI and MCP print the same thing without either inventing a separator.
    pub fn files(&self) -> &[String] {
        match self {
            Self::Applied { files, .. } => files,
            _ => &[],
        }
    }

    pub fn is_applied(&self) -> bool {
        matches!(self, Self::Applied { .. })
    }
}

/// Apply a confirmed plan's pair. Writes nothing but the pair's own endpoints.
///
/// The confirmation gate is **this module's own** and is checked first, for the same
/// reason WP03's is: the gate belongs to the apply, and a caller that reached the
/// write path without one should learn that before the store is read.
pub fn apply_pair(store: &Store, plan: &crate::bulk::BatchPlan) -> Result<PairOutcome> {
    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
        return Err(TixError::InvalidInput(
            "a bulk apply needs a confirmation for exactly this plan — preview it \
             first, and preview again if the selection or the patch changed"
                .to_string(),
        ));
    }
    let op = plan
        .patch
        .relationship
        .as_ref()
        .ok_or_else(|| TixError::InvalidInput("this plan carries no relationship".into()))?;
    // A pair **and** a field change in one patch has no agreed order, and an order
    // invented here would be a policy nobody chose. The patch's own structural
    // check already refuses the combination, so this is belt and braces for a
    // caller that bypassed the constructor.
    if !owns(&plan.patch) {
        return Err(TixError::InvalidInput(
            "this plan mixes a relationship with field changes, which have no \
             agreed order — preview them as two plans"
                .to_string(),
        ));
    }

    let resolution = resolve_pair(store, op);
    if let Some(diagnostic) = resolution.diagnostic {
        return Ok(PairOutcome::Refused(diagnostic));
    }
    let edge = resolution.edge.expect("checked");

    // The delegation. `Store::link` and `Store::unlink` own the canonical edge, the
    // inverse spelling, back-references, idempotence, one History event per
    // accepted mutation, and atomic per-file writes. A refusal from either is the
    // store's judgement, and it has touched nothing.
    //
    // One transaction for the pair's write (#1580), matching the per-item scope
    // `bulk::apply_fields` uses: `unlink` writes every file that carries a matching
    // row, so a failure after the first must not leave the edge half-removed. A
    // bulk pair is one selected issue, never a transaction over the whole batch.
    let outcome = store.transaction(|| {
        if op.link {
            store.link(&op.from, &op.to, edge.kind)
        } else {
            store.unlink(&op.from, &op.to, edge.kind)
        }
    });
    let outcome = match outcome {
        Ok(outcome) => outcome,
        Err(e) => {
            return Ok(PairOutcome::Refused(PairDiagnostic {
                problem: PairProblem::Refused,
                endpoint: "",
                message: e.to_string(),
            }));
        }
    };

    let identity = outcome.relationship.id.clone();
    if !outcome.changed {
        return Ok(PairOutcome::NoOp { identity });
    }
    let files = outcome
        .written
        .as_ref()
        .map(|i| vec![i.filename.clone()])
        .unwrap_or_default();
    Ok(PairOutcome::Applied {
        from: outcome.relationship.from,
        to: outcome.relationship.to,
        identity,
        files,
    })
}

/// The one-line summary a surface prints, with the count named even when zero.
pub fn summary(outcome: &PairOutcome) -> String {
    match outcome {
        PairOutcome::Applied { .. } => "1 relationship applied".to_string(),
        PairOutcome::NoOp { .. } => "1 relationship already in that state".to_string(),
        PairOutcome::Refused(d) => format!("1 relationship refused: {d}"),
    }
}

/// Whether a patch is a **pure** pair, which is what `apply_pair` requires.
///
/// A field change and a pair in one patch is refused at the patch's structural
/// check, so this is belt and braces: the check lives in the type and this
/// re-reads it, because a caller that bypasses the patch constructor should still
/// be stopped here rather than half-applied.
///
/// Note the first version asked `is_field_only`, which is **false** for a pure pair
/// — so `apply_pair` refused every pair plan it was handed, and eleven cases failed
/// with "this plan mixes a relationship with field changes" on a plan that named
/// nothing but a relationship. A predicate whose name reads as the answer to a
/// question it answers the *negation* of is a trap, so both predicates are now
/// named for the path they belong to: `is_field_path` and `is_pair_path`.
pub fn owns(patch: &IssuePatch) -> bool {
    crate::bulk::field_apply::is_pair_path(patch)
}