use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::git_remote::GitOrigin;
use super::flow::ReportFlow;
use super::parser::{ParseError, parse_flow};
pub const SCRATCH_TEMPLATE: &str = "\
# collection:
# output: csv
";
static NEXT_REPORT_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_report_id() -> u64 {
NEXT_REPORT_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone)]
pub struct Report {
pub id: u64,
pub name: String,
pub text: String,
pub path: Option<PathBuf>,
pub git_origin: Option<GitOrigin>,
pub dirty: bool,
}
impl Report {
pub fn scratch(name: impl Into<String>) -> Self {
Self {
id: next_report_id(),
name: name.into(),
text: SCRATCH_TEMPLATE.to_string(),
path: None,
git_origin: None,
dirty: false,
}
}
pub fn from_text(fallback_name: impl Into<String>, text: impl Into<String>) -> Self {
let text = text.into();
let name = header_name(&text).unwrap_or_else(|| fallback_name.into());
Self {
id: next_report_id(),
name,
text,
path: None,
git_origin: None,
dirty: false,
}
}
pub fn flow(&self) -> Result<ReportFlow, ParseError> {
parse_flow(&self.text)
}
pub fn collection_ref(&self) -> Option<String> {
header_directive(&self.text, "collection").filter(|v| !v.is_empty())
}
pub fn environment_ref(&self) -> Option<String> {
header_directive(&self.text, "environment").filter(|v| !v.is_empty())
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
if let Some(n) = header_name(&self.text) {
self.name = n;
}
self.dirty = true;
}
pub fn load_local(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let fallback = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "report".into());
let name = header_name(&text).unwrap_or(fallback);
Ok(Self {
id: next_report_id(),
name,
text,
path: Some(path.to_path_buf()),
git_origin: None,
dirty: false,
})
}
pub fn save_local(&mut self, path: impl AsRef<Path>) -> Result<(), String> {
let path = path.as_ref();
std::fs::write(path, &self.text).map_err(|e| format!("{}: {e}", path.display()))?;
self.path = Some(path.to_path_buf());
self.dirty = false;
Ok(())
}
}
fn header_name(text: &str) -> Option<String> {
header_directive(text, "name").filter(|v| !v.is_empty())
}
pub const OUTPUT_TIME_TOKEN: &str = "{time}";
pub fn name_has_output_token(name: &str) -> bool {
name.contains(OUTPUT_TIME_TOKEN)
}
pub fn expand_output_tokens(name: &str) -> String {
if !name.contains(OUTPUT_TIME_TOKEN) {
return name.to_string();
}
let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M%S").to_string();
name.replace(OUTPUT_TIME_TOKEN, &stamp)
}
fn header_directive(text: &str, key: &str) -> Option<String> {
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let Some(rest) = line.strip_prefix('#') else {
break;
};
if let Some((k, v)) = rest.split_once(':')
&& k.trim().eq_ignore_ascii_case(key)
{
return Some(v.trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scratch_is_unsaved_and_uses_the_template() {
let r = Report::scratch("Untitled");
assert_eq!(r.name, "Untitled");
assert!(r.path.is_none());
assert!(!r.dirty);
assert!(r.text.contains("# collection:"));
assert_eq!(r.collection_ref(), None);
}
#[test]
fn name_and_collection_come_from_the_header() {
let text = "# name: Nightly Smoke\n# collection: ./smoke.hurl\n\nREQUEST Oauth\n";
let r = Report::from_text("fallback", text);
assert_eq!(r.name, "Nightly Smoke");
assert_eq!(r.collection_ref(), Some("./smoke.hurl".to_string()));
}
#[test]
fn from_text_falls_back_when_no_name_directive() {
let r = Report::from_text("fallback", "# collection: c.hurl\nREQUEST x\n");
assert_eq!(r.name, "fallback");
}
#[test]
fn header_is_readable_even_with_a_malformed_body() {
let text = "# collection: c.hurl\nFOR X IN\n";
let r = Report::from_text("fallback", text);
assert!(r.flow().is_err(), "body is intentionally malformed");
assert_eq!(r.collection_ref(), Some("c.hurl".to_string()));
}
#[test]
fn set_text_marks_dirty_and_refreshes_name() {
let mut r = Report::scratch("Untitled");
r.set_text("# name: Renamed\n# collection: c.hurl\nREQUEST x\n");
assert!(r.dirty);
assert_eq!(r.name, "Renamed");
}
#[test]
fn local_save_then_load_round_trips() {
let dir = std::env::temp_dir().join(format!("pb-report-{}", next_report_id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("nightly.trail");
let mut r = Report::from_text("nightly", "# name: N\n# collection: c.hurl\nREQUEST x\n");
r.dirty = true;
r.save_local(&path).unwrap();
assert!(!r.dirty, "save clears the dirty flag");
assert_eq!(r.path.as_deref(), Some(path.as_path()));
let loaded = Report::load_local(&path).unwrap();
assert_eq!(loaded.name, "N");
assert_eq!(loaded.text, r.text);
assert_eq!(loaded.path.as_deref(), Some(path.as_path()));
assert!(!loaded.dirty);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_local_derives_name_from_file_stem_without_name_directive() {
let dir = std::env::temp_dir().join(format!("pb-report-{}", next_report_id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("my-report.trail");
std::fs::write(&path, "# collection: c.hurl\nREQUEST x\n").unwrap();
let loaded = Report::load_local(&path).unwrap();
assert_eq!(loaded.name, "my-report");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn output_token_detection_and_expansion() {
assert!(name_has_output_token("report_{time}"));
assert!(!name_has_output_token("report"));
assert_eq!(expand_output_tokens("nightly"), "nightly");
let out = expand_output_tokens("report_{time}");
assert!(!out.contains("{time}"), "the token must be replaced: {out}");
let stamp = out.strip_prefix("report_").expect("prefix preserved");
let parts: Vec<&str> = stamp.split('-').collect();
assert_eq!(parts.len(), 4, "expected YYYY-MM-DD-HHMMSS, got {stamp}");
assert_eq!(parts[0].len(), 4);
assert_eq!(parts[1].len(), 2);
assert_eq!(parts[2].len(), 2);
assert_eq!(parts[3].len(), 6);
assert!(
parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())),
"all stamp segments must be digits: {stamp}"
);
}
}