use std::path::{Path, PathBuf};
use crate::environment::Environment;
#[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)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvRowKind {
Loaded(u64),
File(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvRow {
pub name: String,
pub kind: EnvRowKind,
pub workspace: bool,
}
impl EnvRow {
pub fn env_id(&self) -> Option<u64> {
match self.kind {
EnvRowKind::Loaded(id) => Some(id),
EnvRowKind::File(_) => None,
}
}
pub fn file(&self) -> Option<&Path> {
match &self.kind {
EnvRowKind::File(p) => Some(p.as_path()),
EnvRowKind::Loaded(_) => None,
}
}
}
pub fn file_display_name(path: &Path) -> String {
path.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("env")
.to_string()
}
pub fn matches(name: &str, filter: &str) -> bool {
let filter = filter.trim();
filter.is_empty() || name.to_lowercase().contains(&filter.to_lowercase())
}
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RevealPlan {
pub clear_filter: bool,
pub widen_source: bool,
}
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));
}
#[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")));
}
#[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));
}
#[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"]
);
}
#[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());
}
#[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
}
);
}
#[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);
}
#[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());
}
}