Skip to main content

systemprompt_cli/commands/analytics/shared/
export.rs

1//! CSV export building with field escaping.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use serde::Serialize;
8use std::fs::{self, File};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use systemprompt_config::ProfileBootstrap;
13use systemprompt_models::AppPaths;
14
15pub fn resolve_export_path(user_path: &Path) -> Result<PathBuf> {
16    if user_path.is_absolute()
17        || user_path
18            .parent()
19            .is_some_and(|p| !p.as_os_str().is_empty())
20    {
21        return Ok(user_path.to_path_buf());
22    }
23
24    let profile = ProfileBootstrap::get().context("Profile not initialized")?;
25    let paths = AppPaths::from_profile(&profile.paths)
26        .map_err(|e| anyhow::anyhow!("Failed to build paths: {}", e))?;
27    let exports_dir = paths.storage().exports().to_path_buf();
28
29    Ok(exports_dir.join(user_path))
30}
31
32pub fn ensure_export_dir(path: &Path) -> Result<()> {
33    if let Some(parent) = path.parent()
34        && !parent.exists()
35    {
36        fs::create_dir_all(parent).context("Failed to create export directory")?;
37    }
38    Ok(())
39}
40
41pub fn export_to_csv<T: Serialize>(data: &[T], path: &Path) -> Result<()> {
42    ensure_export_dir(path)?;
43    let file = File::create(path).context("Failed to create export file")?;
44    let mut writer = std::io::BufWriter::new(file);
45
46    if data.is_empty() {
47        return Ok(());
48    }
49
50    let json_value = serde_json::to_value(&data[0])?;
51    if let serde_json::Value::Object(obj) = json_value {
52        let headers: Vec<&str> = obj.keys().map(String::as_str).collect();
53        writeln!(writer, "{}", headers.join(","))?;
54    }
55
56    for record in data {
57        let json = serde_json::to_value(record)?;
58        if let serde_json::Value::Object(obj) = json {
59            let values: Vec<String> = obj
60                .values()
61                .map(|v| match v {
62                    serde_json::Value::String(s) => escape_csv_field(s),
63                    serde_json::Value::Null => String::new(),
64                    _ => v.to_string(),
65                })
66                .collect();
67            writeln!(writer, "{}", values.join(","))?;
68        }
69    }
70
71    writer.flush()?;
72    Ok(())
73}
74
75pub fn export_single_to_csv<T: Serialize>(data: &T, path: &Path) -> Result<()> {
76    ensure_export_dir(path)?;
77    let file = File::create(path).context("Failed to create export file")?;
78    let mut writer = std::io::BufWriter::new(file);
79
80    let json = serde_json::to_value(data)?;
81    if let serde_json::Value::Object(obj) = json {
82        let headers: Vec<&str> = obj.keys().map(String::as_str).collect();
83        writeln!(writer, "{}", headers.join(","))?;
84
85        let values: Vec<String> = obj
86            .values()
87            .map(|v| match v {
88                serde_json::Value::String(s) => escape_csv_field(s),
89                serde_json::Value::Null => String::new(),
90                _ => v.to_string(),
91            })
92            .collect();
93        writeln!(writer, "{}", values.join(","))?;
94    }
95
96    writer.flush()?;
97    Ok(())
98}
99
100fn escape_csv_field(s: &str) -> String {
101    if s.contains(',') || s.contains('"') || s.contains('\n') {
102        format!("\"{}\"", s.replace('"', "\"\""))
103    } else {
104        s.to_owned()
105    }
106}
107
108#[derive(Debug)]
109pub struct CsvBuilder {
110    headers: Vec<String>,
111    rows: Vec<Vec<String>>,
112}
113
114impl CsvBuilder {
115    pub const fn new() -> Self {
116        Self {
117            headers: Vec::new(),
118            rows: Vec::new(),
119        }
120    }
121
122    pub fn headers(mut self, headers: Vec<&str>) -> Self {
123        self.headers = headers.into_iter().map(String::from).collect();
124        self
125    }
126
127    pub fn add_row(&mut self, row: Vec<String>) {
128        self.rows.push(row);
129    }
130
131    pub fn write_to_file(&self, path: &Path) -> Result<()> {
132        ensure_export_dir(path)?;
133        let mut file = File::create(path).context("Failed to create export file")?;
134
135        writeln!(file, "{}", self.headers.join(","))?;
136
137        for row in &self.rows {
138            let escaped: Vec<String> = row.iter().map(|cell| escape_csv_field(cell)).collect();
139            writeln!(file, "{}", escaped.join(","))?;
140        }
141
142        file.flush().context("Failed to flush export file")?;
143        Ok(())
144    }
145}
146
147impl Default for CsvBuilder {
148    fn default() -> Self {
149        Self::new()
150    }
151}