mod bundler;
mod collector;
pub use bundler::{TicketBundler, preview_ticket};
pub use collector::{SystemInfo, TicketCollector, ToolInfo};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TicketError {
#[error("Failed to collect system info: {0}")]
CollectionFailed(String),
#[error("Failed to create ticket directory: {0}")]
DirectoryCreationFailed(#[from] std::io::Error),
#[error("Failed to create ZIP archive: {0}")]
ArchiveCreationFailed(String),
#[error("Failed to read configuration: {0}")]
ConfigReadFailed(String),
#[error("Invalid ticket ID: {0}")]
InvalidTicketId(String),
}
#[derive(Debug, Clone, Default)]
pub struct TicketScope {
pub system: bool,
pub tools: bool,
pub config: bool,
pub environment: bool,
pub logs: bool,
pub log_lines: usize,
pub tool_filter: Option<String>,
}
impl TicketScope {
pub fn full() -> Self {
Self {
system: true,
tools: true,
config: true,
environment: true,
logs: true,
log_lines: 500,
tool_filter: None,
}
}
pub fn for_tool(tool: &str) -> Self {
Self {
system: true,
tools: true,
config: true,
environment: true,
logs: true,
log_lines: 200,
tool_filter: Some(tool.to_string()),
}
}
pub fn minimal() -> Self {
Self {
system: true,
tools: false,
config: false,
environment: false,
logs: false,
log_lines: 0,
tool_filter: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TicketData {
pub ticket_id: String,
pub created_at: String,
pub jarvy_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<SystemInfo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
pub environment: std::collections::HashMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub logs: Vec<String>,
}
impl TicketData {
pub fn new() -> Self {
let ticket_id = format!(
"JARVY-{}-{}",
chrono::Utc::now().format("%Y%m%d"),
&uuid::Uuid::now_v7().to_string()[..8]
);
Self {
ticket_id,
created_at: chrono::Utc::now().to_rfc3339(),
jarvy_version: env!("CARGO_PKG_VERSION").to_string(),
system: None,
tools: Vec::new(),
config: None,
environment: std::collections::HashMap::new(),
logs: Vec::new(),
}
}
}
impl Default for TicketData {
fn default() -> Self {
Self::new()
}
}
pub fn default_tickets_directory() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".jarvy")
.join("tickets")
}
pub fn list_tickets() -> Result<Vec<(String, PathBuf, u64)>, TicketError> {
let tickets_dir = default_tickets_directory();
let mut tickets = Vec::new();
if !tickets_dir.exists() {
return Ok(tickets);
}
for entry in (std::fs::read_dir(&tickets_dir)?).flatten() {
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "zip").unwrap_or(false) {
if let Some(name) = path.file_stem() {
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
tickets.push((name.to_string_lossy().to_string(), path, size));
}
}
}
tickets.sort_by(|a, b| b.0.cmp(&a.0));
Ok(tickets)
}
pub fn clean_tickets(max_age_days: u32) -> Result<(usize, u64), TicketError> {
let tickets_dir = default_tickets_directory();
let max_age_secs = max_age_days as u64 * 24 * 60 * 60;
let now = std::time::SystemTime::now();
let mut removed_count = 0;
let mut removed_bytes = 0;
if !tickets_dir.exists() {
return Ok((0, 0));
}
for entry in (std::fs::read_dir(&tickets_dir)?).flatten() {
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "zip").unwrap_or(false) {
if let Ok(metadata) = path.metadata() {
if let Ok(modified) = metadata.modified() {
if let Ok(age) = now.duration_since(modified) {
if age.as_secs() > max_age_secs {
removed_bytes += metadata.len();
if std::fs::remove_file(&path).is_ok() {
removed_count += 1;
}
}
}
}
}
}
}
Ok((removed_count, removed_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ticket_data_new() {
let ticket = TicketData::new();
assert!(ticket.ticket_id.starts_with("JARVY-"));
assert!(!ticket.created_at.is_empty());
}
#[test]
fn test_ticket_scope_full() {
let scope = TicketScope::full();
assert!(scope.system);
assert!(scope.tools);
assert!(scope.config);
assert!(scope.logs);
assert_eq!(scope.log_lines, 500);
}
#[test]
fn test_ticket_scope_for_tool() {
let scope = TicketScope::for_tool("git");
assert_eq!(scope.tool_filter, Some("git".to_string()));
}
#[test]
fn test_ticket_scope_minimal() {
let scope = TicketScope::minimal();
assert!(scope.system);
assert!(!scope.tools);
assert!(!scope.logs);
}
#[test]
fn test_default_tickets_directory() {
let dir = default_tickets_directory();
assert!(dir.ends_with(".jarvy/tickets"));
}
}