use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct JsonResponse<T: Serialize> {
pub status: &'static str,
pub command: &'static str,
pub data: T,
}
impl<T: Serialize> JsonResponse<T> {
pub fn success(command: &'static str, data: T) -> Self {
Self {
status: "success",
command,
data,
}
}
}
#[derive(Serialize)]
pub struct JsonErrorResponse {
pub status: &'static str,
pub command: &'static str,
pub error: JsonError,
}
#[derive(Serialize)]
pub struct JsonError {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
impl JsonErrorResponse {
pub fn error(command: &'static str, code: &'static str, message: String) -> Self {
Self {
status: "error",
command,
error: JsonError {
code,
message,
suggestion: None,
},
}
}
pub fn with_suggestion(mut self, suggestion: String) -> Self {
self.error.suggestion = Some(suggestion);
self
}
}
#[derive(Serialize)]
pub struct ListEnvsData {
pub virtualenvs: Vec<VirtualenvInfo>,
pub total: usize,
}
#[derive(Serialize)]
pub struct VirtualenvInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub python: Option<String>,
pub path: String,
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<String>,
}
#[derive(Serialize)]
pub struct ListPythonsData {
pub pythons: Vec<PythonInfo>,
pub total: usize,
}
#[derive(Serialize)]
pub struct PythonInfo {
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub implementation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Serialize)]
pub struct CreateData {
pub name: String,
pub python: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub python_path: Option<String>,
}
#[derive(Serialize)]
pub struct UseData {
pub name: String,
pub mode: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symlink: Option<String>,
}
#[derive(Serialize)]
pub struct RemoveData {
pub name: String,
pub path: String,
}
#[derive(Serialize)]
pub struct InstallData {
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct UninstallData {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_envs: Option<Vec<String>>,
}
#[derive(Serialize)]
pub struct PackageInfo {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
pub struct PackagesInfo {
pub total: usize,
pub items: Vec<PackageInfo>,
pub truncated: bool,
}
impl PackagesInfo {
pub fn new(packages: &[(String, String)], limit: usize) -> Self {
let total = packages.len();
let truncated = total > limit;
Self {
total,
items: packages
.iter()
.take(limit)
.map(|(n, v)| PackageInfo {
name: n.clone(),
version: v.clone(),
})
.collect(),
truncated,
}
}
#[inline]
pub fn remaining(&self) -> usize {
self.total.saturating_sub(self.items.len())
}
}
#[derive(Serialize)]
pub struct WhichData {
pub exe: String,
pub env: String,
pub path: String,
}
#[derive(Serialize)]
pub struct CloneData {
pub src: String,
pub dst: String,
pub python: String,
pub path: String,
pub packages_copied: usize,
pub packages_skipped: bool,
}
#[derive(Serialize)]
pub struct ImportData {
pub name: String,
pub python: String,
pub packages_installed: usize,
pub source: String,
}
#[derive(Serialize)]
pub struct SyncData {
pub manifest_path: String,
pub environment: String,
pub python: String,
pub groups: Vec<String>,
pub packages: Vec<String>,
pub env_created: bool,
pub dry_run: bool,
}
#[derive(Serialize)]
pub struct StatusData {
pub state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub python: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub packages: Option<usize>,
}
#[derive(Serialize)]
pub struct EnvInfoData {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub python: Option<String>,
pub path: String,
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_used: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_display: Option<String>,
pub packages: PackagesInfo,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_json_response_success_creates_correct_status() {
let response = JsonResponse::success("test", "data");
assert_eq!(response.status, "success");
assert_eq!(response.command, "test");
assert_eq!(response.data, "data");
}
#[test]
fn test_json_response_serialization_format() {
let response = JsonResponse::success(
"list",
ListEnvsData {
virtualenvs: vec![],
total: 0,
},
);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains(r#""status":"success""#));
assert!(json.contains(r#""command":"list""#));
assert!(json.contains(r#""total":0"#));
}
#[test]
fn test_json_response_with_string_data() {
let response = JsonResponse::success("echo", "hello world");
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["data"], "hello world");
}
#[test]
fn test_json_response_with_struct_data() {
let response = JsonResponse::success(
"create",
CreateData {
name: "myenv".into(),
python: "3.12".into(),
path: "/path/to/env".into(),
python_path: None,
},
);
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["data"]["name"], "myenv");
assert_eq!(parsed["data"]["python"], "3.12");
}
#[test]
fn test_json_response_with_vec_data() {
let response = JsonResponse::success("list", vec!["env1", "env2", "env3"]);
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["data"].as_array().unwrap().len(), 3);
}
#[test]
fn test_json_error_response_creates_error_status() {
let response = JsonErrorResponse::error("test", "ERR_CODE", "error message".into());
assert_eq!(response.status, "error");
assert_eq!(response.command, "test");
assert_eq!(response.error.code, "ERR_CODE");
assert_eq!(response.error.message, "error message");
assert!(response.error.suggestion.is_none());
}
#[test]
fn test_json_error_response_with_suggestion() {
let response = JsonErrorResponse::error("test", "ERR", "msg".into())
.with_suggestion("try this".into());
assert_eq!(response.error.suggestion, Some("try this".into()));
}
#[test]
fn test_json_error_without_suggestion_omits_field() {
let response = JsonErrorResponse::error("test", "ERR_CODE", "message".into());
let json = serde_json::to_string(&response).unwrap();
assert!(!json.contains("suggestion"));
}
#[test]
fn test_json_error_with_suggestion_includes_field() {
let response = JsonErrorResponse::error("test", "ERR_CODE", "msg".into())
.with_suggestion("try this".into());
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains(r#""suggestion":"try this""#));
}
#[test]
fn test_json_error_response_serialization() {
let response = JsonErrorResponse::error("create", "ENV_EXISTS", "already exists".into())
.with_suggestion("use --force".into());
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["command"], "create");
assert_eq!(parsed["error"]["code"], "ENV_EXISTS");
assert_eq!(parsed["error"]["message"], "already exists");
assert_eq!(parsed["error"]["suggestion"], "use --force");
}
#[test]
fn test_list_envs_data_empty() {
let data = ListEnvsData {
virtualenvs: vec![],
total: 0,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["virtualenvs"].as_array().unwrap().len(), 0);
assert_eq!(parsed["total"], 0);
}
#[test]
fn test_list_envs_data_with_envs() {
let data = ListEnvsData {
virtualenvs: vec![
VirtualenvInfo {
name: "env1".into(),
python: Some("3.12".into()),
path: "/path1".into(),
active: true,
created_at: None,
last_used: None,
},
VirtualenvInfo {
name: "env2".into(),
python: None,
path: "/path2".into(),
active: false,
created_at: None,
last_used: None,
},
],
total: 2,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["virtualenvs"].as_array().unwrap().len(), 2);
assert_eq!(parsed["total"], 2);
}
#[test]
fn test_virtualenv_info_without_python_omits_field() {
let info = VirtualenvInfo {
name: "test".into(),
python: None,
path: "/path".into(),
active: false,
created_at: None,
last_used: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(!json.contains("python"));
}
#[test]
fn test_virtualenv_info_timestamps_present_when_some() {
let info = VirtualenvInfo {
name: "ent".into(),
python: Some("3.12".into()),
path: "/p".into(),
active: false,
created_at: Some("2024-01-15T10:30:00+00:00".to_string()),
last_used: Some("2026-06-02T12:00:00+00:00".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(
json.contains("\"created_at\":\"2024-01-15T10:30:00+00:00\""),
"created_at must serialize when Some: {json}"
);
assert!(
json.contains("\"last_used\":\"2026-06-02T12:00:00+00:00\""),
"last_used must serialize when Some: {json}"
);
}
#[test]
fn test_virtualenv_info_timestamps_omitted_when_none() {
let info = VirtualenvInfo {
name: "ent".into(),
python: None,
path: "/p".into(),
active: false,
created_at: None,
last_used: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(!json.contains("created_at"), "{json}");
assert!(!json.contains("last_used"), "{json}");
}
#[test]
fn test_virtualenv_info_with_python() {
let info = VirtualenvInfo {
name: "test".into(),
python: Some("3.11".into()),
path: "/path".into(),
active: true,
created_at: None,
last_used: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""python":"3.11""#));
assert!(json.contains(r#""active":true"#));
}
#[test]
fn test_list_pythons_data_empty() {
let data = ListPythonsData {
pythons: vec![],
total: 0,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["pythons"].as_array().unwrap().len(), 0);
}
#[test]
fn test_list_pythons_data_with_pythons() {
let data = ListPythonsData {
pythons: vec![
PythonInfo {
version: "3.12.0".into(),
implementation: Some("cpython".into()),
path: Some("/usr/bin/python3.12".into()),
},
PythonInfo {
version: "3.11.0".into(),
implementation: None,
path: None,
},
],
total: 2,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["pythons"].as_array().unwrap().len(), 2);
}
#[test]
fn test_python_info_without_path_omits_field() {
let info = PythonInfo {
version: "3.12".into(),
implementation: None,
path: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(!json.contains("path"));
assert!(!json.contains("implementation"));
}
#[test]
fn test_python_info_with_path() {
let info = PythonInfo {
version: "3.12".into(),
implementation: Some("cpython".into()),
path: Some("/usr/bin/python3".into()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains(r#""path":"/usr/bin/python3""#));
assert!(json.contains(r#""implementation":"cpython""#));
}
#[test]
fn test_python_info_without_implementation_omits_field() {
let info = PythonInfo {
version: "3.12".into(),
implementation: None,
path: Some("/usr/bin/python3".into()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(!json.contains("implementation"));
assert!(json.contains(r#""version":"3.12""#));
}
#[test]
fn test_create_data_serialization() {
let data = CreateData {
name: "myenv".into(),
python: "3.12".into(),
path: "/home/user/.scoop/virtualenvs/myenv".into(),
python_path: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "myenv");
assert_eq!(parsed["python"], "3.12");
assert!(parsed["path"].as_str().unwrap().contains("myenv"));
}
#[test]
fn test_use_data_local_mode() {
let data = UseData {
name: "myenv".into(),
mode: "local",
version_file: Some("/project/.scoop-version".into()),
symlink: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["mode"], "local");
assert!(parsed["version_file"].is_string());
assert!(parsed.get("symlink").is_none());
}
#[test]
fn test_use_data_global_mode() {
let data = UseData {
name: "myenv".into(),
mode: "global",
version_file: None,
symlink: None,
};
let json = serde_json::to_string(&data).unwrap();
assert_eq!(data.mode, "global");
assert!(!json.contains("version_file"));
assert!(!json.contains("symlink"));
}
#[test]
fn test_use_data_with_symlink() {
let data = UseData {
name: "myenv".into(),
mode: "local",
version_file: Some("/project/.scoop-version".into()),
symlink: Some("/project/.venv".into()),
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains(r#""symlink":"/project/.venv""#));
}
#[test]
fn test_remove_data_serialization() {
let data = RemoveData {
name: "oldenv".into(),
path: "/home/user/.scoop/virtualenvs/oldenv".into(),
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "oldenv");
assert!(parsed["path"].as_str().unwrap().contains("oldenv"));
}
#[test]
fn test_install_data_with_path() {
let data = InstallData {
version: "3.12.0".into(),
path: Some("/usr/local/bin/python3.12".into()),
};
let json = serde_json::to_string(&data).unwrap();
assert!(json.contains(r#""path""#));
}
#[test]
fn test_install_data_without_path() {
let data = InstallData {
version: "3.12.0".into(),
path: None,
};
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("path"));
}
#[test]
fn test_uninstall_data_serialization() {
let data = UninstallData {
version: "3.11.0".into(),
removed_envs: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["version"], "3.11.0");
}
#[test]
fn test_json_with_unicode_characters() {
let info = VirtualenvInfo {
name: "한글환경".into(),
python: Some("3.12".into()),
path: "/경로/테스트".into(),
active: true,
created_at: None,
last_used: None,
};
let json = serde_json::to_string(&info).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "한글환경");
assert_eq!(parsed["path"], "/경로/테스트");
}
#[test]
fn test_json_with_empty_strings() {
let data = CreateData {
name: "".into(),
python: "".into(),
path: "".into(),
python_path: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "");
assert_eq!(parsed["python"], "");
}
#[test]
fn test_json_with_special_characters() {
let data = CreateData {
name: r#"test"env"#.into(),
python: "3.12".into(),
path: r#"/path/with\backslash"#.into(),
python_path: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], r#"test"env"#);
assert_eq!(parsed["path"], r#"/path/with\backslash"#);
}
#[test]
fn test_json_with_long_strings() {
let long_name = "a".repeat(1000);
let data = CreateData {
name: long_name.clone(),
python: "3.12".into(),
path: "/path".into(),
python_path: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"].as_str().unwrap().len(), 1000);
}
#[test]
fn test_json_path_with_spaces() {
let data = CreateData {
name: "myenv".into(),
python: "3.12".into(),
path: "/path/with spaces/to/env".into(),
python_path: None,
};
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["path"], "/path/with spaces/to/env");
}
#[test]
fn test_json_with_newlines_and_tabs() {
let data = JsonErrorResponse::error("test", "ERR", "line1\nline2\ttab".into());
let json = serde_json::to_string(&data).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["error"]["message"], "line1\nline2\ttab");
}
#[test]
fn test_json_roundtrip_integrity() {
let original = ListEnvsData {
virtualenvs: vec![
VirtualenvInfo {
name: "env1".into(),
python: Some("3.12".into()),
path: "/path/to/env1".into(),
active: true,
created_at: None,
last_used: None,
},
VirtualenvInfo {
name: "env2".into(),
python: None,
path: "/path/to/env2".into(),
active: false,
created_at: None,
last_used: None,
},
],
total: 2,
};
let json = serde_json::to_string(&original).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["virtualenvs"].as_array().unwrap().len(), 2);
assert_eq!(parsed["virtualenvs"][0]["name"], "env1");
assert_eq!(parsed["virtualenvs"][0]["active"], true);
assert_eq!(parsed["virtualenvs"][1]["name"], "env2");
assert!(parsed["virtualenvs"][1].get("python").is_none());
}
#[test]
fn packages_info_new_under_limit_not_truncated() {
let packages = vec![
("pkg1".to_string(), "1.0".to_string()),
("pkg2".to_string(), "2.0".to_string()),
];
let info = PackagesInfo::new(&packages, 5);
assert_eq!(info.total, 2);
assert_eq!(info.items.len(), 2);
assert!(!info.truncated);
assert_eq!(info.remaining(), 0);
}
#[test]
fn packages_info_new_equal_limit_not_truncated() {
let packages: Vec<(String, String)> = (0..5)
.map(|i| (format!("pkg{}", i), format!("{}.0", i)))
.collect();
let info = PackagesInfo::new(&packages, 5);
assert_eq!(info.total, 5);
assert_eq!(info.items.len(), 5);
assert!(!info.truncated);
assert_eq!(info.remaining(), 0);
}
#[test]
fn packages_info_new_over_limit_truncated() {
let packages: Vec<(String, String)> = (0..10)
.map(|i| (format!("pkg{}", i), format!("{}.0", i)))
.collect();
let info = PackagesInfo::new(&packages, 5);
assert_eq!(info.total, 10);
assert_eq!(info.items.len(), 5);
assert!(info.truncated);
assert_eq!(info.remaining(), 5);
}
#[test]
fn packages_info_new_empty_packages() {
let packages: Vec<(String, String)> = vec![];
let info = PackagesInfo::new(&packages, 5);
assert_eq!(info.total, 0);
assert_eq!(info.items.len(), 0);
assert!(!info.truncated);
assert_eq!(info.remaining(), 0);
}
#[test]
fn packages_info_new_zero_limit_all_truncated() {
let packages = vec![
("pkg1".to_string(), "1.0".to_string()),
("pkg2".to_string(), "2.0".to_string()),
];
let info = PackagesInfo::new(&packages, 0);
assert_eq!(info.total, 2);
assert_eq!(info.items.len(), 0);
assert!(info.truncated);
assert_eq!(info.remaining(), 2);
}
#[test]
fn packages_info_new_preserves_order() {
let packages = vec![
("zeta".to_string(), "3.0".to_string()),
("alpha".to_string(), "1.0".to_string()),
("beta".to_string(), "2.0".to_string()),
];
let info = PackagesInfo::new(&packages, 2);
assert_eq!(info.items[0].name, "zeta");
assert_eq!(info.items[1].name, "alpha");
assert_eq!(info.remaining(), 1);
}
#[test]
fn packages_info_new_copies_version_correctly() {
let packages = vec![
("numpy".to_string(), "1.24.3".to_string()),
("pandas".to_string(), "2.0.1".to_string()),
];
let info = PackagesInfo::new(&packages, 5);
assert_eq!(info.items[0].version, "1.24.3");
assert_eq!(info.items[1].version, "2.0.1");
}
#[test]
fn packages_info_serializes_correctly() {
let packages = vec![
("pkg1".to_string(), "1.0".to_string()),
("pkg2".to_string(), "2.0".to_string()),
];
let info = PackagesInfo::new(&packages, 1);
let json = serde_json::to_string(&info).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["total"], 2);
assert_eq!(parsed["truncated"], true);
assert_eq!(parsed["items"].as_array().unwrap().len(), 1);
assert_eq!(parsed["items"][0]["name"], "pkg1");
}
}