qk-cli 0.5.0

A fast standalone task runner that reads Nx workspaces
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
//! `qk show affected`: why projects are affected.

use std::io::Write;

use anyhow::{Result, bail};
use qk_affected::{Analysis, Cause, Decision, Kept, Reason, TaskDecision};
use qk_config::Workspace;
use qk_graph::graph_order;
use serde_json::json;

/// Packages listed per lockfile reason before summarising the rest.
const LISTED: usize = 20;

pub fn explain(
    workspace: &Workspace,
    analysis: &Analysis,
    project: Option<&str>,
    json: bool,
    out: &mut impl Write,
) -> Result<()> {
    if let Some(project) = project
        && !workspace.projects.contains_key(project)
    {
        bail!("unknown project {project:?}");
    }
    match (project, json) {
        (None, true) => {
            serde_json::to_writer_pretty(&mut *out, analysis)?;
            writeln!(out)?;
        }
        (Some(project), true) => {
            let chain = analysis.chain(project);
            let steps: Vec<_> = chain
                .iter()
                .flatten()
                .map(|name| match analysis.cause(name).expect("a chain step is affected") {
                    Cause::DependsOn {
                        project,
                        kind,
                        also,
                    } => {
                        json!({"project": name, "dependsOn": project, "kind": kind, "alsoDependsOn": also})
                    }
                    Cause::Touched { reasons } => json!({"project": name, "reasons": reasons}),
                })
                .collect();
            let mut report = json!({
                "project": project,
                "affected": chain.is_some(),
                "base": analysis.base,
                "head": analysis.head,
                "chain": steps,
            });
            if let Some(decision) = analysis.reachability.get(project) {
                report["reachability"] = serde_json::to_value(decision)?;
            }
            if !analysis.projections.is_empty() {
                report["projections"] = serde_json::to_value(&analysis.projections)?;
                report["originalFiles"] = serde_json::to_value(&analysis.original_files)?;
                report["files"] = serde_json::to_value(&analysis.files)?;
            }
            serde_json::to_writer_pretty(&mut *out, &report)?;
            writeln!(out)?;
        }
        (None, false) => {
            writeln!(out, "{}", comparison(analysis))?;
            explain_projections(analysis, out)?;
            let names = graph_order(&workspace.projects, analysis.projects.keys().cloned());
            let width = names.iter().map(String::len).max().unwrap_or(0);
            for name in names {
                let summary = match &analysis.projects[&name] {
                    Cause::Touched { reasons } => {
                        let more = match reasons.len() {
                            1 => String::new(),
                            count => format!(" (and {} more)", count - 1),
                        };
                        format!("touched: {}{more}", reasons[0])
                    }
                    Cause::DependsOn { project, .. } => format!("depends on {project}"),
                };
                match analysis.reachability.get(&name) {
                    Some(decision) => writeln!(out, "{name:width$}  {summary}; {decision}")?,
                    None => writeln!(out, "{name:width$}  {summary}")?,
                }
            }
            let left_out = graph_order(
                &workspace.projects,
                analysis
                    .reachability
                    .iter()
                    .filter(|(_, decision)| !matches!(decision, Decision::Kept { .. }))
                    .map(|(name, _)| name.clone()),
            );
            if !left_out.is_empty() {
                writeln!(out, "Left out by import reachability:")?;
                let width = left_out.iter().map(String::len).max().unwrap_or(0);
                for name in left_out {
                    let decision = &analysis.reachability[&name];
                    writeln!(out, "  {name:width$}  {decision}")?;
                }
            }
        }
        (Some(project), false) => {
            writeln!(out, "{}", comparison(analysis))?;
            explain_projections(analysis, out)?;
            let Some(chain) = analysis.chain(project) else {
                match analysis.reachability.get(project) {
                    Some(Decision::LeftOut { tasks }) => {
                        writeln!(
                            out,
                            "{project} is left out: import reachability left out each task of it a change affects."
                        )?;
                        let width = tasks.keys().map(String::len).max().unwrap_or(0);
                        for (id, decision) in tasks {
                            writeln!(out, "  {id:width$}  {decision}")?;
                        }
                    }
                    _ => writeln!(out, "{project} is not affected.")?,
                }
                return Ok(());
            };
            let touched = chain.last().expect("a chain has its project");
            if chain.len() == 1 {
                writeln!(out, "{project} is affected because it is touched:")?;
            } else {
                writeln!(
                    out,
                    "{project} is affected because it depends on {touched}:"
                )?;
                for pair in chain.windows(2) {
                    let Cause::DependsOn { kind, .. } =
                        analysis.cause(pair[0]).expect("a chain step is affected")
                    else {
                        unreachable!("only the last step of a chain is touched");
                    };
                    writeln!(out, "  {} -> {} ({kind})", pair[0], pair[1])?;
                }
                if let Cause::DependsOn { also, .. } = &analysis.projects[project]
                    && !also.is_empty()
                {
                    writeln!(
                        out,
                        "  {project} also depends on affected {} (see `qk show affected <project>`)",
                        also.join(", ")
                    )?;
                }
                writeln!(out, "{touched} is touched:")?;
            }
            let Cause::Touched { reasons } = analysis
                .cause(touched)
                .expect("a chain ends at an affected project")
            else {
                unreachable!("a chain ends at a touched project");
            };
            for reason in reasons {
                writeln!(out, "  {reason}")?;
                if let Reason::Installs {
                    direct,
                    added,
                    removed,
                    changed,
                    ..
                } = reason
                {
                    let lines = installs(direct, added, removed, changed);
                    for line in lines.iter().take(LISTED) {
                        writeln!(out, "      {line}")?;
                    }
                    if lines.len() > LISTED {
                        writeln!(out, "      ... {} more packages", lines.len() - LISTED)?;
                    }
                }
            }
            if let Some(decision @ Decision::Kept { decision: task, .. }) =
                analysis.reachability.get(project)
            {
                writeln!(out, "Import reachability {decision}")?;
                if let Some(TaskDecision::Whole {
                    why: Kept::Reached { chain, .. },
                }) = task
                    && chain.len() > 2
                {
                    for pair in chain.windows(2) {
                        writeln!(out, "  {} imports {}", pair[0], pair[1])?;
                    }
                }
            }
        }
    }
    Ok(())
}

fn explain_projections(analysis: &Analysis, out: &mut impl Write) -> Result<()> {
    for projection in &analysis.projections {
        writeln!(
            out,
            "Projection {}: {}; {} source change{}, {} artifact change{}{}.",
            projection.name,
            projection.status,
            projection.sources.len(),
            if projection.sources.len() == 1 {
                ""
            } else {
                "s"
            },
            projection.artifacts.len(),
            if projection.artifacts.len() == 1 {
                ""
            } else {
                "s"
            },
            projection
                .detail
                .as_ref()
                .map(|detail| format!("; {detail}"))
                .unwrap_or_default()
        )?;
        for path in &projection.artifacts {
            writeln!(out, "  {path}")?;
        }
    }
    Ok(())
}

fn comparison(analysis: &Analysis) -> String {
    let mut text = comparison_of(
        analysis
            .original_files
            .as_ref()
            .map_or(analysis.files.len(), Vec::len),
        analysis.base.as_deref(),
        analysis.head.as_deref(),
        analysis.range.as_ref(),
    );
    if analysis.original_files.is_some() {
        text.push_str(&format!(
            "\n{} {} after projection.",
            analysis.files.len(),
            if analysis.files.len() == 1 {
                "file"
            } else {
                "files"
            }
        ));
    }
    text
}

/// What was compared, then how the base was found and a warning when the
/// range holds commits that already landed.
fn comparison_of(
    count: usize,
    base: Option<&str>,
    head: Option<&str>,
    range: Option<&qk_affected::Range>,
) -> String {
    let short = |revision: &str| revision.chars().take(12).collect::<String>();
    let files = if count == 1 { "file" } else { "files" };
    let mut text = format!(
        "{count} changed {files} between {} and {}.",
        base.map_or("(no base)".into(), short),
        head.map_or("the working tree".into(), short)
    );
    if let Some(range) = range {
        let left = match &range.upstream {
            Some(upstream) => format!("{upstream}, newer than {}", range.requested),
            None => range.requested.clone(),
        };
        let commits = if range.commits == 1 {
            "commit"
        } else {
            "commits"
        };
        text.push_str(&format!(
            "\nThe base is where {} left {left}, {} {commits} back{}.",
            head.map_or("HEAD".into(), short),
            range.commits,
            if head.is_none() {
                ", and the working tree counts too"
            } else {
                ""
            }
        ));
        if let Some(warning) = landed_warning(range) {
            text.push_str(&format!("\nwarning: {warning}"));
        }
    }
    text
}

/// A warning when the compared commits include some already on the default
/// branch, whose changes then count as affected.
pub fn landed_warning(range: &qk_affected::Range) -> Option<String> {
    let branch = range.default_branch.as_deref()?;
    (range.landed > 0).then(|| {
        format!(
            "{} of the {} commits compared are already on {branch}, so what they changed counts as affected; `--base {branch}` compares from where this branch left it",
            range.landed, range.commits
        )
    })
}

/// A lockfile reason by package, most telling first: versions that moved,
/// packages added or removed, then those that kept their version but changed
/// peers or patch, and last those whose dependencies changed, which usually
/// follow from the others. Direct dependencies are marked.
fn installs(
    direct: &[String],
    added: &[String],
    removed: &[String],
    changed: &[String],
) -> Vec<String> {
    use std::collections::{BTreeMap, BTreeSet};
    let direct: BTreeSet<&str> = direct
        .iter()
        .filter_map(|line| line.split_once(':').map(|(name, _)| name))
        .collect();
    #[derive(Default)]
    struct Versions<'a> {
        before: BTreeSet<&'a str>,
        after: BTreeSet<&'a str>,
    }
    let mut packages: BTreeMap<&str, Versions> = BTreeMap::new();
    for key in removed {
        let (name, version) = split(key);
        packages.entry(name).or_default().before.insert(version);
    }
    for key in added {
        let (name, version) = split(key);
        packages.entry(name).or_default().after.insert(version);
    }
    for key in changed {
        packages.entry(split(key).0).or_default();
    }
    let mut lines: Vec<_> = packages
        .into_iter()
        .map(|(name, versions)| {
            let list = |set: &BTreeSet<&str>| set.iter().copied().collect::<Vec<_>>().join(", ");
            let (rank, what) = if versions.before.is_empty() && versions.after.is_empty() {
                (3, "dependencies or integrity changed".to_owned())
            } else if versions.before.is_empty() {
                (1, format!("added {}", list(&versions.after)))
            } else if versions.after.is_empty() {
                (1, format!("removed {}", list(&versions.before)))
            } else if versions.before == versions.after {
                (
                    2,
                    format!("{}: peers or patch changed", list(&versions.after)),
                )
            } else {
                (
                    0,
                    format!("{} -> {}", list(&versions.before), list(&versions.after)),
                )
            };
            let marker = if direct.contains(name) {
                " (direct)"
            } else {
                ""
            };
            (rank, format!("{name}{marker}: {what}"))
        })
        .collect();
    lines.sort();
    lines.into_iter().map(|(_, line)| line).collect()
}

/// A snapshot key's package name and version, without peers or patch.
fn split(key: &str) -> (&str, &str) {
    let key = &key[..key.find('(').unwrap_or(key.len())];
    let start = usize::from(key.starts_with('@'));
    match key[start..].find('@') {
        Some(at) => (&key[..start + at], &key[start + at + 1..]),
        None => (key, ""),
    }
}

/// `qk show tasks`: the planned tasks, or with an analysis the affected ones
/// and why.
pub fn tasks(
    _workspace: &Workspace,
    graph: &qk_taskgraph::TaskGraph,
    analysis: Option<&qk_affected::TaskAnalysis>,
    json: bool,
    out: &mut impl Write,
) -> Result<()> {
    let Some(analysis) = analysis else {
        let ids: Vec<&String> = graph.tasks.keys().collect();
        if json {
            serde_json::to_writer(&mut *out, &ids)?;
            writeln!(out)?;
        } else {
            for id in ids {
                writeln!(out, "{id}")?;
            }
        }
        return Ok(());
    };
    if json {
        serde_json::to_writer_pretty(&mut *out, analysis)?;
        writeln!(out)?;
        return Ok(());
    }
    writeln!(
        out,
        "{}",
        comparison_of(
            analysis.files.len(),
            analysis.base.as_deref(),
            analysis.head.as_deref(),
            analysis.range.as_ref(),
        )
    )?;
    let width = analysis.tasks.keys().map(String::len).max().unwrap_or(0);
    for (id, cause) in &analysis.tasks {
        let summary = match cause {
            qk_affected::TaskCause::Touched { reasons } => {
                let more = match reasons.len() {
                    1 => String::new(),
                    count => format!(" (and {} more)", count - 1),
                };
                format!("{}{more}", reasons[0])
            }
            qk_affected::TaskCause::DependsOn { task } => format!("depends on {task}"),
        };
        match analysis.reachability.get(id) {
            Some(decision) => writeln!(out, "{id:width$}  {summary}; {decision}")?,
            None => writeln!(out, "{id:width$}  {summary}")?,
        }
        if let Some(qk_affected::TaskDecision::Cases { cases, .. }) = analysis.reachability.get(id)
        {
            for (case, why) in cases {
                writeln!(out, "  {case}: {why}")?;
            }
        }
    }
    let left_out: Vec<_> = analysis
        .reachability
        .iter()
        .filter(|(id, _)| !analysis.tasks.contains_key(*id))
        .collect();
    if !left_out.is_empty() {
        writeln!(out, "Left out by import reachability:")?;
        let width = left_out.iter().map(|(id, _)| id.len()).max().unwrap_or(0);
        for (id, decision) in left_out {
            writeln!(out, "  {id:width$}  {decision}")?;
        }
    }
    Ok(())
}