tix-tracker 0.12.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
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
//! `tix view` saved-view management (#172; mission
//! `saved-issue-views-01M3C926`, WP04; FR-001–FR-005, NFR-003, C-001,
//! C-003, C-005–C-008).
//!
//! This module is a **thin adapter**. Every rule it appears to enforce is one
//! WP01–WP03 already own, and it deliberately owns none of its own:
//!
//! | concern | owner | how this module reaches it |
//! |---|---|---|
//! | stable ids, scope metadata, unknown fields | WP01 `views::registry` | the record types and `ViewId` |
//! | normalized plan, validation, apply | WP02 `views::plan` | `ViewPlan`, `ViewVisibility` |
//! | CRUD, definition-only delete, active marker, divergence | WP03 `views::service` | [`ViewService`] |
//! | the query grammar | `crate::search` | `resolve_filter_criteria` + `ViewPlan::from_parts` |
//!
//! Three properties are worth stating because a surface is the easiest place to
//! lose them:
//!
//! - **No second parser.** `--filter` is the same token query `tix list
//!   --filter` accepts, resolved by [`crate::search::resolve_filter_criteria`]
//!   (which lifts `parent:`/`category:`/`scope:` into the typed criteria the
//!   matcher needs) and adapted through `ViewPlan::from_parts`. This module
//!   never walks the query itself (C-002/C-004/C-007).
//! - **Applying writes nothing.** `apply` and `status` take `&ViewService`, so
//!   they cannot reach a mutation; the service's apply borrows the config, the
//!   corpus, and the board. No issue, comment, relationship, or `tix.toml` byte
//!   changes (C-005).
//! - **Deleting needs a deliberate word.** `delete` without `--confirm` is
//!   refused before a [`DeleteConfirmed`] can be built, mirroring the token's
//!   private field. Only the definition is removed (C-005).
//!
//! The active marker is **in-memory in the service** (data-model §4's "if
//! explicitly persisted"). A CLI process therefore has no persisted marker;
//! `apply` installs one for its own run and prints it, and `status` reports the
//! divergence between a saved plan and a **live plan assembled from its own
//! flags** — the honest CLI reading of "live normalized state" (C-006).

use std::collections::BTreeSet;
use std::path::PathBuf;

use crate::cli::{ViewAction, ViewPlanArgs, ViewScopeArgs};
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::search::{Corpus, StructuredFilter};
use crate::storage::Store;
use crate::views::divergence::{ViewDivergence, ViewSurface};
use crate::views::plan::{ViewPlan, ViewVisibility};
use crate::views::registry::{
    ViewDiagnostic, ViewDiagnosticKind, ViewRefusal, ViewScope, ViewScopeKind, ViewWriteError,
};
use crate::views::service::{DeleteConfirmed, ViewDefinition, ViewService};

/// The service this adapter builds: the registry over this build's project
/// directory.
type Service<'a> = ViewService<'a, BTreeSet<String>>;

/// Entry point for `tix view <subcommand>`.
///
/// Takes the config **mutably** because an accepted view mutation is
/// `config.views` changing; every read-only arm simply does not use it.
pub fn run(config: &mut Config, store: &Store, action: &ViewAction) -> Result<()> {
    let projects = project_directory();
    let path = config_path(store);
    // The board and the category resolution `apply` needs are read from the config
    // *before* [`ViewService`] takes the mutable borrow. Both are owned values, so
    // the borrow ends immediately and no arm has to fight for the config.
    let board = config.default_config().clone();
    let resolution = crate::workflow::active_resolution(config).1;

    let mut service = ViewService::new(config, &projects, &path);
    match action {
        ViewAction::List { scope } => list(&service, &projects, scope.as_deref()),
        ViewAction::Show { reference } => show(&service, &projects, reference),
        ViewAction::Create { name, scope, plan } => create(store, &mut service, name, scope, plan),
        ViewAction::Update {
            reference,
            name,
            plan,
        } => update(store, &mut service, reference, name.as_deref(), plan),
        ViewAction::Duplicate { reference, name } => {
            duplicate(&mut service, reference, name.as_deref())
        }
        ViewAction::Delete { reference, confirm } => delete(&mut service, reference, *confirm),
        ViewAction::Apply { reference } => {
            apply(&mut service, store, reference, &board, &resolution)
        }
        ViewAction::Status { reference, live } => {
            status(&service, store, reference.as_deref(), live)
        }
        ViewAction::Share { reference, scope } => share(&mut service, reference, scope),
    }
}

// -- the project directory ------------------------------------------------

/// The project ids a project-scoped view can resolve against.
///
/// `tix` has **no first-class project registry** in this build — the
/// `first-class-projects` mission owns `[projects.<id>]`, and `Config` refuses
/// unknown top-level sections, so no ids can be read from `tix.toml` either. The
/// directory is therefore empty, and a project-scoped view is refused at write
/// time by the registry's own `scope.project_id` diagnostic rather than being
/// silently accepted. The refusal is the registry's, not this adapter's: when
/// the project registry lands, only this function changes.
pub(crate) fn project_directory() -> BTreeSet<String> {
    BTreeSet::new()
}

/// The repository-owned `tix.toml` an accepted mutation commits to.
///
/// Found by walking up from the issue directory, exactly as
/// `commands::cycle::root_from_store` does, so `tix view` run from a
/// subdirectory writes the same file every other command reads.
pub(crate) fn config_path(store: &Store) -> PathBuf {
    Config::project_root(&store.issues_dir).map_or_else(
        || PathBuf::from(".").join("tix.toml"),
        |root| root.join("tix.toml"),
    )
}

// -- read-only verbs ------------------------------------------------------

/// `tix view list` — one tab-separated row per stored view, declaration order.
fn list(service: &Service<'_>, projects: &BTreeSet<String>, scope: Option<&str>) -> Result<()> {
    let wanted = scope.map(parse_scope_kind).transpose()?;
    for view in service.list() {
        if wanted.is_some_and(|kind| view.scope.kind != kind) {
            continue;
        }
        println!(
            "{}\t{}\t{}\t{}",
            view.id,
            view.name,
            scope_label(&view.scope),
            view.scope.resolution(projects)
        );
    }
    Ok(())
}

/// `tix view show` — the record, its normalized plan, and its diagnostics.
fn show(service: &Service<'_>, projects: &BTreeSet<String>, reference: &str) -> Result<()> {
    let detail = service.show(reference).map_err(refusal_error)?;
    let view = detail.view();
    println!("id: {}", view.id);
    println!("name: {}", view.name);
    println!("scope: {}", scope_label(&view.scope));
    println!("resolution: {}", view.scope.resolution(projects));
    for line in plan_lines(detail.plan()) {
        println!("{line}");
    }
    for diagnostic in detail.diagnostics() {
        println!("diagnostic: {}", render_diagnostic(diagnostic));
    }
    Ok(())
}

/// `tix view status` — the saved plan against a live plan assembled here.
///
/// Without a reference there is no saved plan to compare and the report is the
/// service's explicit "no active view" diagnostic — never a silent
/// `in-sync: true` (data-model §5).
fn status(
    service: &Service<'_>,
    store: &Store,
    reference: Option<&str>,
    live_args: &ViewPlanArgs,
) -> Result<()> {
    let live = plan_from_args(store, live_args)?;
    let divergence = match reference {
        Some(reference) => {
            let detail = service.show(reference).map_err(refusal_error)?;
            println!("id: {}", detail.view().id);
            println!("name: {}", detail.view().name);
            ViewDivergence::between(detail.plan(), &live)
        }
        None => ViewDivergence::missing_marker(&live),
    };
    println!("in-sync: {}", divergence.in_sync());
    for difference in divergence.differences() {
        println!(
            "difference: {} saved={} live={}",
            difference.field(),
            difference.saved().unwrap_or("(unset)"),
            difference.live().unwrap_or("(unset)")
        );
    }
    for diagnostic in divergence.diagnostics() {
        println!("diagnostic: {}", render_diagnostic(diagnostic));
    }
    Ok(())
}

// -- mutations ------------------------------------------------------------

fn create(
    store: &Store,
    service: &mut Service<'_>,
    name: &str,
    scope_args: &ViewScopeArgs,
    plan_args: &ViewPlanArgs,
) -> Result<()> {
    let scope = build_scope(scope_args).map_err(refusal_error)?;
    let plan = plan_from_args(store, plan_args)?;
    let outcome = service
        .create(ViewDefinition::new(name, scope, plan))
        .map_err(write_error)?;
    println!("Created {}", outcome.into_value());
    Ok(())
}

/// `tix view update` — merge semantics: only the named fields change.
///
/// `ViewService::update` replaces the whole definition, so the CLI reads the
/// stored one first and carries over whatever the caller did not name. A rename
/// therefore cannot drop the plan or the scope, and the stable id is untouched
/// (C-002).
fn update(
    store: &Store,
    service: &mut Service<'_>,
    reference: &str,
    name: Option<&str>,
    plan_args: &ViewPlanArgs,
) -> Result<()> {
    let detail = service.show(reference).map_err(refusal_error)?;
    let new_name = name
        .map(str::to_string)
        .unwrap_or_else(|| detail.view().name.clone());
    let scope = detail.view().scope.clone();
    let plan = if plan_args.is_any() {
        overlay_plan_args(store, detail.plan(), plan_args)?
    } else {
        detail.plan().clone()
    };

    let outcome = service
        .update(reference, ViewDefinition::new(new_name, scope, plan))
        .map_err(write_error)?;
    let word = if outcome.is_noop() {
        "Unchanged"
    } else {
        "Updated"
    };
    println!("{word} {}", outcome.value());
    Ok(())
}

fn duplicate(service: &mut Service<'_>, reference: &str, name: Option<&str>) -> Result<()> {
    let outcome = service.duplicate(reference, name).map_err(write_error)?;
    println!("Duplicated {}", outcome.into_value());
    Ok(())
}

/// `tix view delete` — definition only, and only under `--confirm`.
fn delete(service: &mut Service<'_>, reference: &str, confirm: bool) -> Result<()> {
    if !confirm {
        return Err(TixError::InvalidInput(
            "deleting a view needs --confirm: it removes the saved definition, never its \
             issues or comments"
                .to_string(),
        ));
    }
    let outcome = service
        .delete(reference, DeleteConfirmed::confirmed())
        .map_err(write_error)?;
    let removed = outcome.value();
    println!("Deleted {}", removed.view().id);
    if removed.marker_cleared() {
        println!("active-marker: cleared");
    }
    Ok(())
}

/// `tix view share` — replace the scope metadata, keeping the name and plan.
fn share(service: &mut Service<'_>, reference: &str, scope_args: &ViewScopeArgs) -> Result<()> {
    let detail = service.show(reference).map_err(refusal_error)?;
    let name = detail.view().name.clone();
    let plan = detail.plan().clone();
    let scope = build_scope(scope_args).map_err(refusal_error)?;

    let outcome = service
        .update(reference, ViewDefinition::new(name, scope, plan))
        .map_err(write_error)?;
    let word = if outcome.is_noop() {
        "Unchanged"
    } else {
        "Shared"
    };
    println!("{word} {}", outcome.value());
    Ok(())
}

/// `tix view apply` — read-only apply, then the active marker and the result.
///
/// The marker is installed **after** a successful apply so a refused plan cannot
/// be marked, and it is informational: nothing on disk changes (C-005/C-006).
fn apply(
    service: &mut Service<'_>,
    store: &Store,
    reference: &str,
    board: &BoardConfiguration,
    resolution: &crate::workflow::CategoryResolution,
) -> Result<()> {
    let detail = service.show(reference).map_err(refusal_error)?;
    service
        .mark_active(reference, Some(ViewSurface::Cli))
        .map_err(refusal_error)?;
    let corpus = Corpus::build(store)?;
    let result = service
        .apply(reference, board, &corpus, resolution)
        .map_err(refusal_error)?;

    println!("id: {}", detail.view().id);
    println!("name: {}", detail.view().name);
    println!("scope: {}", scope_label(&detail.view().scope));
    for line in plan_lines(detail.plan()) {
        println!("{line}");
    }
    println!("active: {}", detail.view().id);
    println!("issues: {}", join_numbers(result.issue_numbers()));
    println!("count: {}", result.issue_numbers().len());
    for diagnostic in result.diagnostics() {
        println!("diagnostic: {}", render_diagnostic(diagnostic));
    }
    Ok(())
}

// -- argument adaptation --------------------------------------------------

/// Overlay only the plan fields the caller **named** onto a stored plan.
///
/// `update` is merge-semantics, so an unnamed field must survive. Rebuilding the
/// plan from the flags would silently discard a saved filter, sort, group, or
/// visibility the caller did not mention, contradicting the subcommand's own
/// `--help` (WP04 review: `update <id> --sort created` dropped the stored
/// `status:todo` filter and `cycle` group). A named `--filter` replaces the
/// filter and free text; a `sort:`/`group:` token *inside* it still counts as
/// naming that field, so a stored value is carried over only when neither the
/// flag nor a token names it.
pub(crate) fn overlay_plan_args(
    store: &Store,
    base: &ViewPlan,
    args: &ViewPlanArgs,
) -> Result<ViewPlan> {
    let mut plan = match args.filter.as_deref() {
        Some(filter) => {
            let (query, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
            let mut rebuilt = ViewPlan::from_parts(criteria, &query);
            if args.sort.is_none() && rebuilt.sort().is_none() {
                if let Some(sort) = base.sort() {
                    rebuilt = rebuilt.with_sort(sort);
                }
            }
            if args.group.is_none() && rebuilt.group().is_none() {
                if let Some(group) = base.group() {
                    rebuilt = rebuilt.with_group(group);
                }
            }
            if args.hide_columns.is_none() && args.show_mismatch.is_none() {
                if let Some(visibility) = base.visibility() {
                    rebuilt = rebuilt.with_visibility(visibility.clone());
                }
            }
            rebuilt.with_extensions(base.extensions().clone())
        }
        None => base.clone(),
    };

    if let Some(sort) = args.sort.as_deref() {
        plan = plan.with_sort(sort);
    }
    if let Some(group) = args.group.as_deref() {
        plan = plan.with_group(group);
    }
    if args.hide_columns.is_some() || args.show_mismatch.is_some() {
        let hidden = split_list(args.hide_columns.as_deref());
        plan = plan.with_visibility(ViewVisibility::new(hidden, args.show_mismatch));
    }
    Ok(plan)
}

/// Build the plan from the existing grammar's flags.
///
/// `--filter` goes through the **existing** resolver so `parent:`, `category:`,
/// and `scope:` tokens become the typed criteria the matcher needs instead of
/// silently matching nothing; the remaining query and those criteria are then
/// adapted by `ViewPlan::from_parts`. Sort/group are the same control tokens, and
/// visibility is present exactly when one of its two flags was given — omitting
/// them means "inherit the live board", which is a different plan from "hide
/// nothing".
pub(crate) fn plan_from_args(store: &Store, args: &ViewPlanArgs) -> Result<ViewPlan> {
    let (query, criteria) = match args.filter.as_deref() {
        Some(filter) => crate::search::resolve_filter_criteria(store, filter)?,
        None => (String::new(), StructuredFilter::default()),
    };
    let mut plan = ViewPlan::from_parts(criteria, &query);
    if let Some(sort) = args.sort.as_deref() {
        plan = plan.with_sort(sort);
    }
    if let Some(group) = args.group.as_deref() {
        plan = plan.with_group(group);
    }
    if args.hide_columns.is_some() || args.show_mismatch.is_some() {
        let hidden = split_list(args.hide_columns.as_deref());
        plan = plan.with_visibility(ViewVisibility::new(hidden, args.show_mismatch));
    }
    Ok(plan)
}

/// The explicit scope a caller asked for, refused by the registry's own
/// structural rules rather than by this function.
///
/// A `repository` scope carrying a project id, a `project` scope with no id, or
/// a `user` scope with neither owner nor shared-with are all returned as-is: the
/// service's `validate_definition` runs `ViewScope::conflicts` and reports them
/// with the same diagnostics a stored record would produce.
pub(crate) fn build_scope(args: &ViewScopeArgs) -> std::result::Result<ViewScope, ViewRefusal> {
    let kind = match args.scope.as_deref() {
        None | Some("repository") => ViewScopeKind::Repository,
        Some("project") => ViewScopeKind::Project,
        Some("user" | "user_share") => ViewScopeKind::UserShare,
        Some(other) => {
            return Err(ViewDiagnostic::new(
                ViewDiagnosticKind::InvalidView,
                format!("unknown view scope `{other}`; expected repository, project, or user"),
            )
            .with_field("scope")
            .with_remedy("pass `--scope repository`, `--scope project`, or `--scope user`")
            .into());
        }
    };
    Ok(ViewScope {
        kind,
        project_id: args.project.clone(),
        owner: args.owner.clone(),
        shared_with: split_list(args.shared_with.as_deref()),
        extensions: toml::Table::new(),
    })
}

pub(crate) fn parse_scope_kind(raw: &str) -> Result<ViewScopeKind> {
    match raw {
        "repository" | "repo" => Ok(ViewScopeKind::Repository),
        "project" => Ok(ViewScopeKind::Project),
        "user" | "user_share" | "share" => Ok(ViewScopeKind::UserShare),
        other => Err(TixError::InvalidInput(format!(
            "unknown view scope `{other}`; expected repository, project, or user"
        ))),
    }
}

/// A comma-separated flag value as a trimmed, non-empty list.
fn split_list(raw: Option<&str>) -> Vec<String> {
    raw.unwrap_or_default()
        .split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .collect()
}

/// One `key: value` line per plan field that is set.
pub(crate) fn plan_lines(plan: &ViewPlan) -> Vec<String> {
    let mut lines = vec![format!("filter: {}", plan.filter())];
    if let Some(query) = plan.text_query() {
        lines.push(format!("query: {query}"));
    }
    if let Some(sort) = plan.sort() {
        lines.push(format!("sort: {sort}"));
    }
    if let Some(group) = plan.group() {
        lines.push(format!("group: {group}"));
    }
    if let Some(visibility) = plan.visibility() {
        lines.push(format!(
            "hide-columns: {}",
            visibility.hidden_columns().join(",")
        ));
        lines.push(format!(
            "show-mismatch: {}",
            visibility
                .show_mismatch()
                .map_or_else(|| "inherit".to_string(), |value| value.to_string())
        ));
    }
    lines
}

/// The scope family and its metadata, as one human-readable token.
pub(crate) fn scope_label(scope: &ViewScope) -> String {
    match scope.kind {
        ViewScopeKind::Repository => "repository".to_string(),
        ViewScopeKind::Project => {
            format!("project {}", scope.project_id.as_deref().unwrap_or(""))
        }
        ViewScopeKind::UserShare => {
            let mut parts = vec!["user".to_string()];
            if let Some(owner) = scope.owner.as_deref() {
                parts.push(format!("owner={owner}"));
            }
            if !scope.shared_with.is_empty() {
                parts.push(format!("shared_with={}", scope.shared_with.join(",")));
            }
            parts.join(" ")
        }
    }
}

/// One diagnostic as `kind [field]: message`.
pub(crate) fn render_diagnostic(diagnostic: &ViewDiagnostic) -> String {
    match diagnostic.field() {
        Some(field) => format!("{} [{field}]: {}", diagnostic.kind(), diagnostic.message()),
        None => format!("{}: {}", diagnostic.kind(), diagnostic.message()),
    }
}

/// The applied issue numbers, comma-separated, or `(none)`.
pub(crate) fn join_numbers(numbers: &[u32]) -> String {
    if numbers.is_empty() {
        "(none)".to_string()
    } else {
        numbers
            .iter()
            .map(u32::to_string)
            .collect::<Vec<_>>()
            .join(",")
    }
}

// -- error adaptation -----------------------------------------------------

/// A refusal is a decision, not a failure: an unknown view is "not found"
/// (exit 2), everything else is a rejected input (exit 1).
fn refusal_error(refusal: ViewRefusal) -> TixError {
    let rendered = refusal.render();
    if refusal.kind() == ViewDiagnosticKind::UnknownView {
        TixError::NotFound(rendered)
    } else {
        TixError::InvalidInput(rendered)
    }
}

/// A failed write keeps the registry's refusal/failure split: a refusal is the
/// registry declining (nothing written), a failure is the atomic save not
/// completing.
fn write_error(error: ViewWriteError) -> TixError {
    match error {
        ViewWriteError::Refused(refusal) => refusal_error(refusal),
        ViewWriteError::Failed(error) => error,
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::views::registry::ScopeResolution;

    #[test]
    fn scope_labels_name_the_family_and_its_metadata() {
        assert_eq!(scope_label(&ViewScope::repository()), "repository");
        assert_eq!(
            scope_label(&ViewScope::project("p-platform")),
            "project p-platform"
        );
        assert_eq!(
            scope_label(&ViewScope::user_share(
                Some("alice".to_string()),
                vec!["bob".to_string()]
            )),
            "user owner=alice shared_with=bob"
        );
    }

    #[test]
    fn a_repository_scope_carrying_a_project_is_left_for_the_registry_to_refuse() {
        let args = ViewScopeArgs {
            scope: None,
            project: Some("p-platform".to_string()),
            owner: None,
            shared_with: None,
        };
        let scope = build_scope(&args).unwrap();
        assert_eq!(scope.kind, ViewScopeKind::Repository);
        assert!(
            !scope.conflicts().is_empty(),
            "the adapter must not silently drop the project id"
        );
    }

    #[test]
    fn an_unknown_scope_word_is_refused_with_the_expected_vocabulary() {
        let args = ViewScopeArgs {
            scope: Some("team".to_string()),
            project: None,
            owner: None,
            shared_with: None,
        };
        let refusal = build_scope(&args).unwrap_err();
        assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
        assert!(refusal.message().contains("repository, project, or user"));
    }

    #[test]
    fn split_list_trims_and_drops_blanks() {
        assert_eq!(split_list(Some(" a, b ,,c ")), vec!["a", "b", "c"]);
        assert_eq!(split_list(None), [] as [std::string::String; 0]);
    }

    #[test]
    fn join_numbers_is_comma_separated_and_says_none() {
        assert_eq!(join_numbers(&[1, 2, 3]), "1,2,3");
        assert_eq!(join_numbers(&[]), "(none)");
    }

    #[test]
    fn an_unresolvable_project_scope_reports_missing_rather_than_resolved() {
        let scope = ViewScope::project("p-platform");
        let empty: BTreeSet<String> = BTreeSet::new();
        assert_eq!(scope.resolution(&empty), ScopeResolution::Missing);
        let mut known = BTreeSet::new();
        known.insert("p-platform".to_string());
        assert_eq!(scope.resolution(&known), ScopeResolution::Resolved);
    }
}