use std::collections::VecDeque;
use std::sync::mpsc::Receiver;
use std::time::Instant;
use camino::Utf8PathBuf;
use crate::config::{Mode, SyncProfile};
use crate::player::engine::{PlayerEvent, PlayerHandle};
use crate::scan::ScanResult;
use crate::transfer::{ProgressEvent, Stats};
use crate::tui::theme::Theme;
use crate::tui::views::player::PlayerState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Home,
Profiles,
Diff,
Progress,
Log,
NewProfile,
Player,
}
pub enum DiffState {
Idle,
Loading,
Ready {
result: Box<crate::diff::DiffResult>,
source: Utf8PathBuf,
destination: Utf8PathBuf,
profile_name: String,
dap_id: String,
mode: Mode,
},
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryFilter {
All,
New,
Modified,
Orphan,
Same,
}
impl EntryFilter {
pub fn label(self) -> &'static str {
match self {
Self::All => "ALL",
Self::New => "NEW [+]",
Self::Modified => "MODIFIED [~]",
Self::Orphan => "ORPHAN [-]",
Self::Same => "SAME [=]",
}
}
pub fn next(self) -> Self {
match self {
Self::All => Self::New,
Self::New => Self::Modified,
Self::Modified => Self::Orphan,
Self::Orphan => Self::Same,
Self::Same => Self::All,
}
}
pub fn matches(self, kind: crate::diff::EntryKind) -> bool {
use crate::diff::EntryKind;
match self {
Self::All => true,
Self::New => kind == EntryKind::New,
Self::Modified => kind == EntryKind::Modified,
Self::Orphan => kind == EntryKind::Orphan,
Self::Same => kind == EntryKind::Same,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WizardStep {
Name,
Source,
Destination,
Mode,
Confirm,
}
impl WizardStep {
pub fn number(self) -> usize {
match self {
Self::Name => 1,
Self::Source => 2,
Self::Destination => 3,
Self::Mode => 4,
Self::Confirm => 5,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Name => "profile name",
Self::Source => "source path",
Self::Destination => "destination",
Self::Mode => "sync mode",
Self::Confirm => "confirm",
}
}
pub fn prev(self) -> Option<Self> {
match self {
Self::Name => None,
Self::Source => Some(Self::Name),
Self::Destination => Some(Self::Source),
Self::Mode => Some(Self::Destination),
Self::Confirm => Some(Self::Mode),
}
}
pub fn next(self) -> Option<Self> {
match self {
Self::Name => Some(Self::Source),
Self::Source => Some(Self::Destination),
Self::Destination => Some(Self::Mode),
Self::Mode => Some(Self::Confirm),
Self::Confirm => None,
}
}
}
pub struct FileBrowserState {
pub current: camino::Utf8PathBuf,
pub entries: Vec<String>,
pub cursor: usize,
pub at_drives_root: bool,
}
impl FileBrowserState {
pub fn new(start: camino::Utf8PathBuf) -> Self {
let mut s = Self {
current: start,
entries: vec![],
cursor: 0,
at_drives_root: false,
};
s.refresh();
s
}
pub fn drives_root() -> Self {
let entries = available_drives();
Self {
current: camino::Utf8PathBuf::from(""),
entries,
cursor: 0,
at_drives_root: true,
}
}
pub fn refresh(&mut self) {
if self.at_drives_root {
self.entries = available_drives();
return;
}
self.entries.clear();
if let Ok(rd) = std::fs::read_dir(self.current.as_std_path()) {
let mut dirs: Vec<String> = rd
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
dirs.sort_by_key(|a| a.to_lowercase());
self.entries = dirs;
}
}
pub fn total_items(&self) -> usize {
if self.at_drives_root {
self.entries.len()
} else {
self.entries.len() + 1
}
}
pub fn move_down(&mut self) {
if self.cursor + 1 < self.total_items() {
self.cursor += 1;
}
}
pub fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn enter_selected(&mut self) -> bool {
if self.at_drives_root {
if let Some(drive) = self.entries.get(self.cursor).cloned() {
self.current = camino::Utf8PathBuf::from(&drive);
self.at_drives_root = false;
self.cursor = 0;
self.refresh();
}
return false;
}
if self.cursor == 0 {
return true; }
let idx = self.cursor - 1;
if let Some(name) = self.entries.get(idx).cloned() {
self.current = self.current.join(&name);
self.cursor = 0;
self.refresh();
}
false
}
pub fn go_up(&mut self) {
if self.at_drives_root {
return; }
if is_fs_root(&self.current) {
let prev = self.current.as_str().to_owned();
self.at_drives_root = true;
self.current = camino::Utf8PathBuf::from("");
self.entries = available_drives();
self.cursor = self.entries.iter().position(|e| e == &prev).unwrap_or(0);
return;
}
let prev = self.current.file_name().unwrap_or("").to_owned();
if let Some(parent) = self.current.parent() {
self.current = camino::Utf8PathBuf::from(parent);
self.refresh();
self.cursor = self
.entries
.iter()
.position(|e| e == &prev)
.map(|i| i + 1) .unwrap_or(0);
}
}
pub fn location_label(&self) -> &str {
if self.at_drives_root {
"drives"
} else {
self.current.as_str()
}
}
}
fn is_fs_root(path: &camino::Utf8Path) -> bool {
#[cfg(windows)]
{
let s = path.as_str();
s.len() <= 3 && s.contains(':')
}
#[cfg(not(windows))]
{
path.as_str() == "/"
}
}
fn available_drives() -> Vec<String> {
#[cfg(windows)]
{
('A'..='Z')
.map(|c| format!("{c}:\\"))
.filter(|d| std::path::Path::new(d).exists())
.collect()
}
#[cfg(not(windows))]
{
vec!["/".to_owned()]
}
}
pub struct NewProfileState {
pub step: WizardStep,
pub name: tui_input::Input,
pub source_browser: FileBrowserState,
pub dest_choice: usize,
pub dest_browser: Option<FileBrowserState>,
pub mode_choice: usize,
pub error: Option<String>,
pub cloned_from: Option<String>,
}
impl NewProfileState {
pub fn new(scan: &crate::scan::ScanResult) -> Self {
let source_browser = FileBrowserState::drives_root();
let dest_browser = scan
.identified
.first()
.map(|id| FileBrowserState::new(camino::Utf8PathBuf::from(&id.mount.mount_point)))
.or_else(|| {
scan.unidentified
.first()
.map(|m| FileBrowserState::new(camino::Utf8PathBuf::from(&m.mount_point)))
})
.unwrap_or_else(FileBrowserState::drives_root);
Self {
step: WizardStep::Name,
name: tui_input::Input::default(),
source_browser,
dest_choice: 0,
dest_browser: Some(dest_browser),
mode_choice: 0,
error: None,
cloned_from: None,
}
}
pub fn from_clone(
profile: &crate::config::SyncProfile,
scan: &crate::scan::ScanResult,
) -> Self {
let original_name = profile.profile.name.clone();
let src_path = camino::Utf8PathBuf::from(&profile.profile.source);
let source_browser = if src_path.exists() {
FileBrowserState::new(src_path)
} else {
FileBrowserState::drives_root()
};
let manual_idx = scan.identified.len();
let dest = &profile.profile.destination;
let (dest_choice, dest_browser) = if let Some(dap_id) = dest.strip_prefix("auto:") {
let idx = scan
.identified
.iter()
.position(|id| id.dap_id == dap_id)
.unwrap_or(manual_idx);
let browser = scan
.identified
.first()
.map(|id| FileBrowserState::new(camino::Utf8PathBuf::from(&id.mount.mount_point)))
.unwrap_or_else(FileBrowserState::drives_root);
(idx, Some(browser))
} else {
let dest_path = camino::Utf8PathBuf::from(dest.as_str());
let browser = if dest_path.exists() {
FileBrowserState::new(dest_path)
} else {
FileBrowserState::drives_root()
};
(manual_idx, Some(browser))
};
let mode_choice = match profile.profile.mode {
Mode::Mirror => 1,
_ => 0,
};
let suggested_name: tui_input::Input = format!("{original_name}-copy").into();
Self {
step: WizardStep::Name,
name: suggested_name,
source_browser,
dest_choice,
dest_browser,
mode_choice,
error: None,
cloned_from: Some(original_name),
}
}
pub fn source(&self) -> String {
self.source_browser.current.to_string()
}
pub fn destination(&self, scan: &crate::scan::ScanResult) -> String {
let manual_idx = scan.identified.len();
if self.dest_choice == manual_idx {
self.dest_browser
.as_ref()
.map(|b| b.current.to_string())
.unwrap_or_default()
} else {
scan.identified
.get(self.dest_choice)
.map(|id| format!("auto:{}", id.dap_id))
.unwrap_or_default()
}
}
pub fn selected_dap(&self) -> &'static str {
"generic"
}
pub fn selected_mode(&self) -> &'static str {
match self.mode_choice {
0 => "additive",
1 => "mirror",
_ => "selective",
}
}
}
const MAX_RECENT: usize = 200;
pub struct ProgressState {
pub profile_name: String,
pub total_bytes: u64,
pub done_bytes: u64,
pub current_file: String,
pub current_file_bytes: u64,
pub current_file_done: u64,
pub copied: usize,
pub deleted: usize,
pub failed: usize,
pub recent: VecDeque<RecentLine>,
pub finished: bool,
pub finish_stats: Option<Stats>,
pub recorded: bool,
started: Instant,
}
pub struct RecentLine {
pub icon: &'static str,
pub path: String,
pub ok: bool,
}
impl ProgressState {
pub fn new(profile_name: String, total_bytes: u64) -> Self {
Self {
profile_name,
total_bytes,
done_bytes: 0,
current_file: String::new(),
current_file_bytes: 0,
current_file_done: 0,
copied: 0,
deleted: 0,
failed: 0,
recent: VecDeque::with_capacity(MAX_RECENT + 1),
finished: false,
finish_stats: None,
recorded: false,
started: Instant::now(),
}
}
pub fn speed_bps(&self) -> f64 {
let secs = self.started.elapsed().as_secs_f64();
if secs < 0.5 {
0.0
} else {
self.done_bytes as f64 / secs
}
}
pub fn eta_secs(&self) -> u64 {
let speed = self.speed_bps();
if speed < 1.0 || self.total_bytes == 0 {
return 0;
}
let remaining = self.total_bytes.saturating_sub(self.done_bytes);
(remaining as f64 / speed) as u64
}
pub fn handle_event(&mut self, event: ProgressEvent) {
match event {
ProgressEvent::FileStart { path, bytes } => {
self.current_file = path;
self.current_file_bytes = bytes;
self.current_file_done = 0;
}
ProgressEvent::FileProgress { bytes } => {
self.current_file_done += bytes;
self.done_bytes += bytes;
}
ProgressEvent::FileDone { path, bytes: _ } => {
self.copied += 1;
self.current_file.clear();
self.push_recent(RecentLine {
icon: "[+]",
path,
ok: true,
});
}
ProgressEvent::FileFail { path, err } => {
self.failed += 1;
self.current_file.clear();
self.push_recent(RecentLine {
icon: "[!]",
path: format!("{path} ({err})"),
ok: false,
});
}
ProgressEvent::DeleteDone { path } => {
self.deleted += 1;
self.push_recent(RecentLine {
icon: "[-]",
path,
ok: true,
});
}
ProgressEvent::Finish { stats } => {
self.finished = true;
self.finish_stats = Some(stats);
self.current_file.clear();
}
}
}
fn push_recent(&mut self, line: RecentLine) {
if self.recent.len() >= MAX_RECENT {
self.recent.pop_front();
}
self.recent.push_back(line);
}
}
pub struct App {
pub view: View,
pub theme: Theme,
pub profiles: Vec<(String, SyncProfile)>,
pub scan: ScanResult,
pub profile_idx: usize,
pub home_cursor: usize,
pub should_quit: bool,
pub flash: Option<String>,
pub flash_ticks: u8,
pub confirm_sync: bool,
pub delete_confirm: bool,
pub diff_state: DiffState,
pub diff_entry_idx: usize,
pub diff_entry_filter: EntryFilter,
pub progress_rx: Option<Receiver<ProgressEvent>>,
pub progress_state: Option<ProgressState>,
pub wizard: Option<NewProfileState>,
pub log_lines: Vec<LogEntry>,
pub log_scroll: usize,
pub log_run_id: String,
pub player_state: Option<PlayerState>,
pub player_handle: Option<PlayerHandle>,
pub player_rx: Option<Receiver<PlayerEvent>>,
pub scan_rx: Option<Receiver<crate::player::scanner::ScanEvent>>,
pub resume_candidate: Option<crate::player::history::HistoryEntry>,
pub last_sync: std::collections::HashMap<String, u64>,
pub selective_paths: std::collections::HashSet<String>,
pub selective_init_pending: bool,
}
pub struct LogEntry {
pub time: String,
pub level: LogLevel,
pub event: String,
pub detail: String,
}
impl LogEntry {
pub fn level_str(&self) -> &'static str {
match self.level {
LogLevel::Info => "info",
LogLevel::Warn => "warn",
LogLevel::Error => "error",
LogLevel::Other => "?",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Warn,
Error,
Other,
}
impl App {
pub fn new() -> anyhow::Result<Self> {
let discovered = crate::config::discover()?;
let mut profiles = Vec::with_capacity(discovered.len());
for (name, path) in discovered {
match crate::config::load(&path) {
Ok(p) => profiles.push((name, p)),
Err(e) => tracing::warn!(profile = %path.display(), err = %e, "skipping profile"),
}
}
let scan = crate::scan::run_scan().unwrap_or_else(|e| {
tracing::warn!(err = %e, "scan failed");
ScanResult {
identified: vec![],
unidentified: vec![],
}
});
Ok(Self {
view: View::Home,
theme: Theme::new(),
profiles,
scan,
profile_idx: 0,
home_cursor: 0,
should_quit: false,
flash: None,
flash_ticks: 0,
confirm_sync: false,
delete_confirm: false,
diff_state: DiffState::Idle,
diff_entry_idx: 0,
diff_entry_filter: EntryFilter::All,
progress_rx: None,
progress_state: None,
wizard: None,
log_lines: Vec::new(),
log_scroll: 0,
log_run_id: String::new(),
player_state: None,
player_handle: None,
player_rx: None,
scan_rx: None,
resume_candidate: None,
last_sync: load_last_sync().unwrap_or_default(),
selective_paths: std::collections::HashSet::new(),
selective_init_pending: false,
})
}
pub fn selected_profile(&self) -> Option<&SyncProfile> {
self.profiles.get(self.profile_idx).map(|(_, p)| p)
}
pub fn home_move_up(&mut self) {
self.home_cursor = self.home_cursor.saturating_sub(1);
}
pub fn home_move_down(&mut self) {
self.home_cursor = (self.home_cursor + 1).min(2); }
pub fn move_up(&mut self) {
if self.profile_idx > 0 {
self.profile_idx -= 1;
}
}
pub fn move_down(&mut self) {
if !self.profiles.is_empty() && self.profile_idx + 1 < self.profiles.len() {
self.profile_idx += 1;
}
}
pub fn delete_current_profile(&mut self) -> anyhow::Result<()> {
let Some((name, _)) = self.profiles.get(self.profile_idx) else {
return Ok(());
};
let name = name.clone();
let dir = crate::config::profiles_dir()?;
let path = dir.join(format!("{name}.toml"));
std::fs::remove_file(&path)?;
let discovered = crate::config::discover()?;
self.profiles.clear();
for (n, p) in discovered {
match crate::config::load(&p) {
Ok(profile) => self.profiles.push((n, profile)),
Err(e) => tracing::warn!(profile = %p.display(), err = %e, "skipping profile"),
}
}
if self.profile_idx > 0 && self.profile_idx >= self.profiles.len() {
self.profile_idx -= 1;
}
Ok(())
}
pub fn refresh_scan(&mut self) {
if let Ok(s) = crate::scan::run_scan() {
self.scan = s;
self.set_flash("scan refreshed");
}
}
pub fn enter_diff(&mut self) {
self.diff_state = DiffState::Loading;
self.diff_entry_idx = 0;
self.diff_entry_filter = EntryFilter::All;
if let Some((_, profile)) = self.profiles.get(self.profile_idx) {
if matches!(profile.profile.mode, crate::config::Mode::Selective) {
let saved = &profile.selective.include_paths;
if saved.is_empty() {
self.selective_paths.clear();
self.selective_init_pending = true;
} else {
self.selective_paths = saved.iter().cloned().collect();
self.selective_init_pending = false;
}
} else {
self.selective_paths.clear();
self.selective_init_pending = false;
}
}
self.view = View::Diff;
}
pub fn move_diff_up(&mut self) {
self.diff_entry_idx = self.diff_entry_idx.saturating_sub(1);
}
pub fn move_diff_down(&mut self) {
self.diff_entry_idx = self.diff_entry_idx.saturating_add(1);
}
pub fn cycle_diff_filter(&mut self) {
self.diff_entry_filter = self.diff_entry_filter.next();
self.diff_entry_idx = 0;
}
pub fn drain_progress(&mut self) {
let rx = match self.progress_rx.as_ref() {
Some(r) => r,
None => return,
};
while let Ok(event) = rx.try_recv() {
if let Some(ref mut ps) = self.progress_state {
ps.handle_event(event);
}
}
if let Some(ref mut ps) = self.progress_state {
if ps.finished && !ps.recorded {
ps.recorded = true;
let name = ps.profile_name.clone();
self.record_last_sync(&name);
}
}
}
pub fn enter_new_profile(&mut self) {
self.wizard = Some(NewProfileState::new(&self.scan));
self.view = View::NewProfile;
}
pub fn enter_clone_profile(&mut self) {
let Some((_, profile)) = self.profiles.get(self.profile_idx) else {
return;
};
let profile = profile.clone();
self.wizard = Some(NewProfileState::from_clone(&profile, &self.scan));
self.view = View::NewProfile;
}
pub fn enter_player(&mut self) {
if self.player_state.is_none() {
match crate::player::engine::spawn() {
Some((handle, rx)) => {
self.player_state = Some(PlayerState::new(true));
self.player_handle = Some(handle);
self.player_rx = Some(rx);
}
None => {
self.player_state = Some(PlayerState::new(false));
}
}
}
self.view = View::Player;
}
pub fn enter_player_from_profile(&mut self) {
let source = self
.profiles
.get(self.profile_idx)
.map(|(_, p)| p.profile.source.clone());
self.enter_player();
let Some(src) = source else { return };
let Some(ref handle) = self.player_handle else {
return;
};
let tracks = collect_source_tracks(&src);
if tracks.is_empty() {
if let Some(ref mut ps) = self.player_state {
ps.flash = Some(format!("no audio files found in {src}"));
}
return;
}
let root = camino::Utf8PathBuf::from(&src);
let index = crate::player::library::LibraryIndex::from_tracks(tracks.clone(), &root);
if let Some(ref mut ps) = self.player_state {
ps.set_library(index);
if let Some(ref mut lib) = ps.library {
lib.scanning = true;
}
}
let (scan_tx, scan_rx) = std::sync::mpsc::channel();
crate::player::scanner::spawn_scan(root, scan_tx);
self.scan_rx = Some(scan_rx);
if let Some(resume) = crate::player::history::load_resume() {
self.resume_candidate = Some(resume);
}
handle.send(crate::player::engine::PlayerCommand::LoadQueue(tracks));
}
pub fn drain_player(&mut self) {
let rx = match self.player_rx.as_ref() {
Some(r) => r,
None => return,
};
if let Some(ref mut ps) = self.player_state {
ps.drain_events(rx);
}
if let Some(ref resume) = self.resume_candidate {
let matches = self
.player_state
.as_ref()
.and_then(|ps| ps.status.current.as_ref())
.map(|t| t.path.as_str() == resume.path)
.unwrap_or(false);
if matches {
let pos = std::time::Duration::from_secs_f64(resume.position_secs);
if let Some(ref handle) = self.player_handle {
handle.send(crate::player::engine::PlayerCommand::Seek(pos));
}
let m = resume.position_secs as u64 / 60;
let s = resume.position_secs as u64 % 60;
let msg = format!("resuming from {m}:{s:02}");
if let Some(ref mut ps) = self.player_state {
ps.flash = Some(msg);
}
self.resume_candidate = None;
}
}
}
pub fn drain_scan(&mut self) {
use crate::player::scanner::ScanEvent;
use crate::tui::views::player::LibraryState;
while let Some(rx) = self.scan_rx.as_ref() {
let event = match rx.try_recv() {
Ok(e) => e,
Err(_) => break,
};
match event {
ScanEvent::Progress { done, total } => {
if let Some(ref mut ps) = self.player_state {
if let Some(ref mut lib) = ps.library {
lib.scan_done = done;
lib.scan_total = total;
}
}
}
ScanEvent::Done(index) => {
if let Some(ref mut ps) = self.player_state {
let focused = ps.focus;
let mut new_lib = LibraryState::new(index);
new_lib.scanning = false;
ps.library = Some(new_lib);
ps.focus = focused;
}
self.scan_rx = None;
break;
}
ScanEvent::Error(e) => {
self.set_flash(format!("library scan error: {e}"));
if let Some(ref mut ps) = self.player_state {
if let Some(ref mut lib) = ps.library {
lib.scanning = false;
}
}
self.scan_rx = None;
break;
}
}
}
}
pub fn load_log(&mut self) {
let Some(path) = latest_jsonl_path() else {
self.log_lines = vec![LogEntry {
time: String::new(),
level: LogLevel::Warn,
event: "no runs found".into(),
detail: String::new(),
}];
self.log_run_id = String::new();
self.log_scroll = 0;
return;
};
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
self.log_lines = vec![LogEntry {
time: String::new(),
level: LogLevel::Error,
event: format!("cannot read log: {e}"),
detail: String::new(),
}];
return;
}
};
self.log_run_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
self.log_lines = content
.lines()
.filter(|l| !l.trim().is_empty())
.map(parse_jsonl_line)
.collect();
self.log_scroll = self.log_lines.len().saturating_sub(1);
self.view = View::Log;
}
pub fn set_flash(&mut self, msg: impl Into<String>) {
self.flash = Some(msg.into());
self.flash_ticks = 12;
}
pub fn tick_flash(&mut self) {
if self.flash.is_some() {
self.flash_ticks = self.flash_ticks.saturating_sub(1);
if self.flash_ticks == 0 {
self.flash = None;
}
}
}
pub fn record_last_sync(&mut self, profile_name: &str) {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.last_sync.insert(profile_name.to_owned(), ts);
save_last_sync(&self.last_sync);
}
}
const AUDIO_EXTS: &[&str] = &[
"flac", "mp3", "aac", "ogg", "opus", "wav", "alac", "m4a", "dsf", "dff", "wv", "wma", "aiff",
"aif", "ape",
];
fn collect_source_tracks(source_dir: &str) -> Vec<crate::player::queue::TrackInfo> {
let mut tracks: Vec<_> = walkdir::WalkDir::new(source_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|ext| AUDIO_EXTS.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
})
.filter_map(|e| camino::Utf8PathBuf::from_path_buf(e.into_path()).ok())
.map(crate::player::queue::TrackInfo::from_path)
.collect();
tracks.sort_by(|a, b| a.path.cmp(&b.path));
tracks
}
fn last_sync_path() -> Option<std::path::PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "dapctl")?;
Some(dirs.data_local_dir().join("last_sync.json"))
}
fn load_last_sync() -> Option<std::collections::HashMap<String, u64>> {
let path = last_sync_path()?;
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_last_sync(map: &std::collections::HashMap<String, u64>) {
if let Some(path) = last_sync_path() {
if let Ok(json) = serde_json::to_string(map) {
let _ = std::fs::write(path, json);
}
}
}
fn latest_jsonl_path() -> Option<std::path::PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "dapctl")?;
let runs_dir = dirs.data_local_dir().join("runs");
let mut files: Vec<_> = std::fs::read_dir(&runs_dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
.collect();
files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
files.last().map(|e| e.path())
}
fn parse_jsonl_line(line: &str) -> LogEntry {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
return LogEntry {
time: String::new(),
level: LogLevel::Other,
event: line.to_owned(),
detail: String::new(),
};
};
let time = v["ts"].as_str().unwrap_or("").to_owned();
let time = time.get(11..19).unwrap_or(&time).to_owned();
let level = match v["level"].as_str().unwrap_or("") {
"info" => LogLevel::Info,
"warn" => LogLevel::Warn,
"error" => LogLevel::Error,
_ => LogLevel::Other,
};
let fields = v["fields"].as_object();
let event = fields
.and_then(|f| f.get("event"))
.and_then(|e| e.as_str())
.unwrap_or("")
.to_owned();
let detail = fields
.map(|f| {
f.iter()
.filter(|(k, _)| k.as_str() != "event")
.map(|(k, val)| {
let v = match val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
format!("{k}={v}")
})
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
LogEntry {
time,
level,
event,
detail,
}
}