refactor: introduce cargo workspace + psysonic-core crate (M1/7)

First milestone of the workspace crate split. Sets up the workspace
skeleton and extracts shared logging + cross-crate port traits into a
new psysonic-core crate.

  src-tauri/Cargo.toml            now defines [workspace]; top package
                                  becomes the workspace root.
  crates/psysonic-core/           new lib crate, no Tauri-handler code:
    src/logging.rs                full logging facade (was src/logging.rs)
    src/ports.rs                  PlaybackQuery + AnalysisOrchestrator
                                  trait declarations (no impls yet)

The top crate re-exports `psysonic_core::logging` and the
`app_eprintln!` / `app_deprintln!` macros so every existing
`crate::logging::*` and `crate::app_eprintln!` callsite keeps working
unchanged.

Port traits intentionally take `tauri::AppHandle` — psysonic-core is a
workspace-internal crate, so depending on Tauri here is a feature, not
a leak. Implementers will register themselves as
`Arc<dyn PlaybackQuery>` / `Arc<dyn AnalysisOrchestrator>` Tauri State
in M2/M3.

Behaviour preserving. Cargo check + clippy --workspace clean.
This commit is contained in:
Psychotoxical
2026-05-09 13:06:28 +02:00
parent 9455879044
commit 7718ac3ee5
7 changed files with 104 additions and 5 deletions
@@ -0,0 +1,8 @@
//! `psysonic-core` — workspace-internal shared primitives.
//!
//! Hosts the runtime logging facade (with `app_eprintln!` / `app_deprintln!`
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod logging;
pub mod ports;
@@ -0,0 +1,197 @@
//! Runtime logging facade.
//!
//! Provides level-gated `eprintln!` macros (`app_eprintln!` / `app_deprintln!`)
//! that also append to a bounded in-memory ring buffer and a CLI-readable
//! per-runtime log file. Live mode toggling at runtime via
//! `set_logging_mode_from_str("off"|"normal"|"debug")`.
#[cfg(unix)]
use libc;
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum LoggingMode {
Off = 0,
Normal = 1,
Debug = 2,
}
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
const LOG_BUFFER_MAX_LINES: usize = 20_000;
fn log_buffer() -> &'static Mutex<VecDeque<String>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
}
/// Shared runtime file used by CLI `--tail` to read normal/debug log channel.
pub fn cli_log_channel_path() -> std::path::PathBuf {
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return std::path::PathBuf::from(dir).join("psysonic-cli.log");
}
}
std::env::temp_dir().join("psysonic-cli.log")
}
fn parse_logging_mode(mode: &str) -> Option<LoggingMode> {
match mode.trim().to_ascii_lowercase().as_str() {
"off" => Some(LoggingMode::Off),
"normal" => Some(LoggingMode::Normal),
"debug" => Some(LoggingMode::Debug),
_ => None,
}
}
pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> {
let parsed = parse_logging_mode(mode)
.ok_or_else(|| "invalid logging mode (expected: off | normal | debug)".to_string())?;
LOGGING_MODE.store(parsed as u8, Ordering::Release);
Ok(())
}
fn current_mode() -> LoggingMode {
match LOGGING_MODE.load(Ordering::Acquire) {
0 => LoggingMode::Off,
2 => LoggingMode::Debug,
_ => LoggingMode::Normal,
}
}
pub fn should_log_normal() -> bool {
!matches!(current_mode(), LoggingMode::Off)
}
pub fn should_log_debug() -> bool {
matches!(current_mode(), LoggingMode::Debug)
}
pub fn append_log_line(line: String) {
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(line.clone());
drop(buf);
let path = cli_log_channel_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{}", line);
}
}
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
let snapshot = {
let buf = log_buffer().lock().unwrap();
if buf.is_empty() {
String::new()
} else {
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
s.push('\n');
s
}
};
std::fs::write(path, snapshot).map_err(|e| e.to_string())?;
let lines = {
let buf = log_buffer().lock().unwrap();
buf.len()
};
Ok(lines)
}
pub fn log_timestamp_local() -> String {
let now = ::std::time::SystemTime::now()
.duration_since(::std::time::UNIX_EPOCH)
.unwrap_or_default();
let millis = now.subsec_millis();
#[cfg(unix)]
{
use std::ffi::CStr;
let secs: libc::time_t = now.as_secs() as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let mut date_buf: [libc::c_char; 64] = [0; 64];
let mut tz_buf: [libc::c_char; 16] = [0; 16];
let date_fmt = b"%Y-%m-%d %H:%M:%S\0";
let tz_fmt = b"%z\0";
unsafe {
if libc::localtime_r(&secs as *const libc::time_t, &mut tm as *mut libc::tm).is_null() {
return format!("{}.{:03}", now.as_secs(), millis);
}
let date_ok = libc::strftime(
date_buf.as_mut_ptr(),
date_buf.len(),
date_fmt.as_ptr().cast(),
&tm as *const libc::tm,
);
if date_ok == 0 {
return format!("{}.{:03}", now.as_secs(), millis);
}
let tz_ok = libc::strftime(
tz_buf.as_mut_ptr(),
tz_buf.len(),
tz_fmt.as_ptr().cast(),
&tm as *const libc::tm,
);
let date = CStr::from_ptr(date_buf.as_ptr()).to_string_lossy();
if tz_ok == 0 {
return format!("{}.{:03}", date, millis);
}
let tz = CStr::from_ptr(tz_buf.as_ptr()).to_string_lossy();
return format!("{}.{:03} {}", date, millis, tz);
}
}
#[cfg(not(unix))]
{
format!("{}.{:03}", now.as_secs(), millis)
}
}
#[macro_export]
macro_rules! app_eprintln {
() => {{
if $crate::logging::should_log_normal() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}]", ts);
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
($($arg:tt)*) => {{
if $crate::logging::should_log_normal() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}] {}", ts, format_args!($($arg)*));
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
}
#[macro_export]
macro_rules! app_deprintln {
() => {{
if $crate::logging::should_log_debug() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}]", ts);
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
($($arg:tt)*) => {{
if $crate::logging::should_log_debug() {
let ts = $crate::logging::log_timestamp_local();
let line = format!("[{}] {}", ts, format_args!($($arg)*));
$crate::logging::append_log_line(line.clone());
::std::eprintln!("{}", line);
}
}};
}
@@ -0,0 +1,49 @@
//! Cross-crate port traits.
//!
//! These traits exist purely to break dependency cycles that would otherwise
//! force `psysonic-audio` ↔ `psysonic-analysis` to depend on each other in
//! both directions. Implementers register themselves as Tauri State on app
//! bootstrap; consumers look them up via `app.try_state::<Arc<dyn _>>()`.
//!
//! Layout:
//! - `AnalysisOrchestrator` — implemented in `psysonic-analysis`, called from
//! `psysonic-audio` to enqueue full-buffer loudness/waveform analysis.
//! - `PlaybackQuery` — implemented in `psysonic-audio` on `AudioEngine`,
//! called from `psysonic-analysis` to ask "is this track currently playing?".
use std::future::Future;
use std::pin::Pin;
use tauri::AppHandle;
/// Read-only queries about the live playback session. Implemented on
/// `AudioEngine` in `psysonic-audio`.
///
/// Registered as `Arc<dyn PlaybackQuery>` in Tauri State so non-audio crates
/// can query without taking a hard dep on the audio crate.
pub trait PlaybackQuery: Send + Sync + 'static {
/// `true` if `track_id` is the track currently being decoded/played.
fn is_track_currently_playing(&self, track_id: &str) -> bool;
}
/// Triggers full-buffer analysis on already-captured track bytes. Implemented
/// on `AnalysisRuntime` in `psysonic-analysis`.
///
/// Registered as `Arc<dyn AnalysisOrchestrator>` in Tauri State so audio
/// callsites (`stream::ranged_http`, `stream::track_stream`, `helpers`,
/// `play_input`, `preload_commands`) can submit without depending on
/// `psysonic-analysis`.
pub trait AnalysisOrchestrator: Send + Sync + 'static {
/// Enqueue a CPU-seed analysis pass for `track_id` over the given byte
/// buffer. `high_priority` mirrors the HTTP-backfill head-insertion
/// behaviour for the currently playing track.
///
/// Errors are stringified — audio callers only care about pass/fail.
fn submit_cpu_seed<'a>(
&'a self,
app: AppHandle,
track_id: String,
bytes: Vec<u8>,
high_priority: bool,
) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
}