paperboy 0.5.5

A Rust TUI API tester
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
//! The row model behind the Environments panel, shared by both front-ends so
//! the terminal UI and the GUI list the same things in the same order.
//!
//! The panel is not simply "the loaded environments". When a Workspace folder
//! is open it also lists every environment *file* in that folder, whether or
//! not it has been opened yet — a workspace of a few hundred exports is
//! otherwise invisible until each file is hunted down in the tree and opened
//! one at a time. A workspace file that *has* been loaded is shown once, as
//! the loaded environment it became, rather than twice.
//!
//! Both kinds are filterable by name, which is the point of the whole exercise:
//! with hundreds of environments, finding the one you want by scrolling is
//! hopeless.

use std::path::{Path, PathBuf};

use crate::environment::Environment;

/// Which source(s) the Environments panel should list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub enum EnvSource {
    #[default]
    Both,
    Global,
    Workspace,
}

impl EnvSource {
    pub fn next(self) -> Self {
        match self {
            Self::Both => Self::Global,
            Self::Global => Self::Workspace,
            Self::Workspace => Self::Both,
        }
    }

    fn includes_workspace(self) -> bool {
        matches!(self, Self::Both | Self::Workspace)
    }

    fn includes_global(self) -> bool {
        matches!(self, Self::Both | Self::Global)
    }
}

/// What a panel row points at.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvRowKind {
    /// A loaded environment, by [`Environment::id`]. Everything the panel can
    /// do — activate, link, rename, edit, save, delete — applies to these.
    Loaded(u64),
    /// An environment file in the open workspace that hasn't been loaded yet.
    /// Selecting it loads it, at which point it becomes a `Loaded` row.
    File(PathBuf),
}

/// One row of the Environments panel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvRow {
    /// The name to display: the environment's name, or an unloaded file's stem.
    pub name: String,
    pub kind: EnvRowKind,
    /// True when this row is (or would be) an environment from the open
    /// Workspace folder, as opposed to one loaded from anywhere else. Marked in
    /// both front-ends so the two sources are distinguishable at a glance.
    pub workspace: bool,
}

impl EnvRow {
    /// The loaded environment's id, or `None` for a workspace file that hasn't
    /// been opened yet.
    pub fn env_id(&self) -> Option<u64> {
        match self.kind {
            EnvRowKind::Loaded(id) => Some(id),
            EnvRowKind::File(_) => None,
        }
    }

    /// The workspace file this row would load, or `None` if it is already
    /// loaded.
    pub fn file(&self) -> Option<&Path> {
        match &self.kind {
            EnvRowKind::File(p) => Some(p.as_path()),
            EnvRowKind::Loaded(_) => None,
        }
    }
}

/// The name an unloaded environment file is listed under: its file stem, the
/// same name [`crate::session::Session::open_workspace_environment`] gives the
/// environment once it is loaded, so a row doesn't rename itself on opening.
pub fn file_display_name(path: &Path) -> String {
    path.file_stem()
        .and_then(|n| n.to_str())
        .unwrap_or("env")
        .to_string()
}

/// Whether `name` matches `filter` — a case-insensitive substring, with an
/// empty filter matching everything.
pub fn matches(name: &str, filter: &str) -> bool {
    let filter = filter.trim();
    filter.is_empty() || name.to_lowercase().contains(&filter.to_lowercase())
}

/// Build the Environments panel's rows.
///
/// `workspace_files` is every environment file in the open Workspace folder
/// (see [`crate::collection::Collection::workspace_env_files`]), or empty when
/// no workspace is open. Workspace rows come first, in tree order, followed by the loaded
/// environments that didn't come from the workspace — so opening a workspace
/// doesn't reshuffle the environments already in the list.
///
/// A loaded environment is matched to a workspace file by its `path`, which is
/// how it is distinguished from an identically-named environment loaded from
/// elsewhere.
pub fn rows(
    envs: &[Environment],
    workspace_files: &[PathBuf],
    filter: &str,
    source: EnvSource,
) -> Vec<EnvRow> {
    let mut out = Vec::new();

    if source.includes_workspace() {
        for path in workspace_files {
            let loaded = envs.iter().find(|e| e.path.as_deref() == Some(path));
            let (name, kind) = match loaded {
                Some(env) => (env.name.clone(), EnvRowKind::Loaded(env.id)),
                None => (file_display_name(path), EnvRowKind::File(path.clone())),
            };
            if matches(&name, filter) {
                out.push(EnvRow {
                    name,
                    kind,
                    workspace: true,
                });
            }
        }
    }

    if source.includes_global() {
        for env in envs {
            let in_workspace = env
                .path
                .as_ref()
                .is_some_and(|p| workspace_files.contains(p));
            if in_workspace || !matches(&env.name, filter) {
                continue;
            }
            out.push(EnvRow {
                name: env.name.clone(),
                kind: EnvRowKind::Loaded(env.id),
                workspace: false,
            });
        }
    }

    out
}

/// What a "go to the active environment" gesture has to change before the row
/// can be shown at all.
///
/// Both filters are *widened* rather than respected. With a few hundred rows
/// the active environment is usually somewhere behind a filter the user set for
/// something else, and a "take me to it" that silently did nothing because the
/// row was hidden would be worse than no control at all. Nothing is touched
/// when the row was visible all along, so the common case costs the user
/// nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RevealPlan {
    pub clear_filter: bool,
    pub widen_source: bool,
}

/// Work out the [`RevealPlan`] for `id` under the panel's current filters.
/// Returns `None` when the environment has no row at all under *any* filter —
/// it has been closed, and there is nowhere to go.
pub fn reveal_plan(
    envs: &[Environment],
    workspace_files: &[PathBuf],
    filter: &str,
    source: EnvSource,
    id: u64,
) -> Option<RevealPlan> {
    let shown = |filter: &str, source: EnvSource| {
        rows(envs, workspace_files, filter, source)
            .iter()
            .any(|r| r.env_id() == Some(id))
    };
    if shown(filter, source) {
        return Some(RevealPlan::default());
    }
    if shown("", source) {
        return Some(RevealPlan {
            clear_filter: true,
            widen_source: false,
        });
    }
    if shown("", EnvSource::Both) {
        return Some(RevealPlan {
            clear_filter: !filter.is_empty(),
            widen_source: true,
        });
    }
    None
}

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

    fn env(name: &str, path: Option<&str>) -> Environment {
        let (mut e, _) = parse_vars_pending(name.to_string(), "K=v");
        e.path = path.map(PathBuf::from);
        e
    }

    #[test]
    fn loaded_environments_list_when_no_workspace_is_open() {
        let envs = vec![env("staging", None), env("prod", None)];
        let rows = rows(&envs, &[], "", EnvSource::Both);
        assert_eq!(
            rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
            vec!["staging", "prod"]
        );
        assert!(rows.iter().all(|r| !r.workspace));
        assert_eq!(rows[0].env_id(), Some(envs[0].id));
    }

    /// A workspace file that hasn't been opened still gets a row, so the whole
    /// folder is browsable from the panel rather than only via the tree.
    #[test]
    fn unloaded_workspace_files_are_listed_alongside_loaded_environments() {
        let envs = vec![env("hand-made", None)];
        let files = vec![
            PathBuf::from("/ws/Prod AU.json"),
            PathBuf::from("/ws/dev.vars"),
        ];
        let rows = rows(&envs, &files, "", EnvSource::Both);
        assert_eq!(
            rows.iter()
                .map(|r| (r.name.as_str(), r.workspace, r.env_id().is_some()))
                .collect::<Vec<_>>(),
            vec![
                ("Prod AU", true, false),
                ("dev", true, false),
                ("hand-made", false, true),
            ],
            "workspace files come first, in tree order, then the loaded rest"
        );
        assert_eq!(rows[0].file(), Some(Path::new("/ws/Prod AU.json")));
    }

    /// Once opened, a workspace file is the loaded environment — it must not
    /// appear a second time in the global section.
    #[test]
    fn an_opened_workspace_file_is_listed_once_as_the_loaded_environment() {
        let envs = vec![env("dev", Some("/ws/dev.vars"))];
        let files = vec![PathBuf::from("/ws/dev.vars")];
        let rows = rows(&envs, &files, "", EnvSource::Both);
        assert_eq!(rows.len(), 1);
        assert!(rows[0].workspace, "it is still a workspace environment");
        assert_eq!(rows[0].env_id(), Some(envs[0].id));
    }

    /// An environment loaded from outside the workspace keeps its own row even
    /// when it shares a name with one inside it.
    #[test]
    fn a_same_named_environment_from_elsewhere_is_not_folded_into_the_workspace_row() {
        let envs = vec![env("dev", Some("/elsewhere/dev.vars"))];
        let files = vec![PathBuf::from("/ws/dev.vars")];
        let rows = rows(&envs, &files, "", EnvSource::Both);
        assert_eq!(rows.len(), 2);
        assert_eq!((rows[0].workspace, rows[1].workspace), (true, false));
    }

    #[test]
    fn the_filter_matches_case_insensitively_on_any_part_of_the_name() {
        let envs = vec![env("Westpac Prod", None), env("Bendigo Staging", None)];
        let files = vec![PathBuf::from("/ws/Westpac NZ Staging.json")];

        let matched = rows(&envs, &files, "staging", EnvSource::Both);
        assert_eq!(
            matched.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
            vec!["Westpac NZ Staging", "Bendigo Staging"]
        );

        assert_eq!(
            rows(&envs, &files, "  ", EnvSource::Both).len(),
            3,
            "a blank filter is no filter"
        );
        assert!(rows(&envs, &files, "zzz", EnvSource::Both).is_empty());
    }

    #[test]
    fn the_source_filter_limits_rows_to_global_workspace_or_both() {
        let envs = vec![
            env("hand-made", None),
            env("dev", Some("/ws/dev.vars")),
            env("outside-dev", Some("/elsewhere/dev.vars")),
        ];
        let files = vec![PathBuf::from("/ws/dev.vars")];

        assert_eq!(
            rows(&envs, &files, "", EnvSource::Both)
                .iter()
                .map(|r| (r.name.as_str(), r.workspace))
                .collect::<Vec<_>>(),
            vec![("dev", true), ("hand-made", false), ("outside-dev", false)]
        );
        assert_eq!(
            rows(&envs, &files, "", EnvSource::Workspace)
                .iter()
                .map(|r| r.name.as_str())
                .collect::<Vec<_>>(),
            vec!["dev"]
        );
        assert_eq!(
            rows(&envs, &files, "", EnvSource::Global)
                .iter()
                .map(|r| r.name.as_str())
                .collect::<Vec<_>>(),
            vec!["hand-made", "outside-dev"]
        );
    }

    #[test]
    fn the_source_filter_composes_with_the_name_filter() {
        let envs = vec![env("prod global", None), env("stage global", None)];
        let files = vec![
            PathBuf::from("/ws/prod workspace.vars"),
            PathBuf::from("/ws/stage workspace.vars"),
        ];

        assert_eq!(
            rows(&envs, &files, "prod", EnvSource::Workspace)
                .iter()
                .map(|r| r.name.as_str())
                .collect::<Vec<_>>(),
            vec!["prod workspace"]
        );
        assert_eq!(
            rows(&envs, &files, "prod", EnvSource::Global)
                .iter()
                .map(|r| r.name.as_str())
                .collect::<Vec<_>>(),
            vec!["prod global"]
        );
    }

    /// Nothing is disturbed when the active environment is already on show —
    /// the common case must not cost the user their filter.
    #[test]
    fn revealing_a_visible_environment_changes_no_filters() {
        let envs = vec![env("staging", None), env("prod", None)];
        let plan = reveal_plan(&envs, &[], "", EnvSource::Both, envs[1].id).expect("has a row");
        assert_eq!(plan, RevealPlan::default());
    }

    /// Hidden by the text filter alone: clear that, and leave the source be.
    #[test]
    fn revealing_an_environment_behind_the_text_filter_clears_only_the_filter() {
        let envs = vec![env("staging", None), env("prod", None)];
        let plan = reveal_plan(&envs, &[], "stag", EnvSource::Both, envs[1].id).expect("has a row");
        assert_eq!(
            plan,
            RevealPlan {
                clear_filter: true,
                widen_source: false
            }
        );
    }

    /// A global environment hidden by a Workspace-only source needs the source
    /// widened as well; the text filter goes too, since it was set for a list
    /// the user is no longer looking at.
    #[test]
    fn revealing_a_global_environment_under_a_workspace_filter_widens_the_source() {
        let dir = std::env::temp_dir().join("paperboy-reveal-plan");
        let _ = std::fs::create_dir_all(&dir);
        let file = dir.join("ws.vars");
        let _ = std::fs::write(&file, "K=v");
        let envs = vec![env("prod", None)];
        let plan = reveal_plan(
            &envs,
            &[file.clone()],
            "zzz",
            EnvSource::Workspace,
            envs[0].id,
        )
        .expect("has a row once the source is widened");
        assert_eq!(
            plan,
            RevealPlan {
                clear_filter: true,
                widen_source: true
            }
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// An id that isn't in the list at all has nowhere to go, and says so
    /// rather than clearing the user's filters for nothing.
    #[test]
    fn revealing_an_environment_that_is_no_longer_loaded_gives_no_plan() {
        let envs = vec![env("prod", None)];
        assert!(reveal_plan(&envs, &[], "", EnvSource::Both, envs[0].id + 999).is_none());
    }
}