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
}
#[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"]
);
}
}