[update] refactored configuration handling with comprehensive ConfigFile structure, added default settings, expanded support for new modules, and enhanced directory creation logic

This commit is contained in:
2025-08-06 16:54:10 +02:00
parent c3b0c87bfa
commit 78073d27d7
4 changed files with 607 additions and 101 deletions

View File

@@ -1,3 +0,0 @@
[server]
host = '127.0.0.1'
port = 8090

View File

@@ -1,127 +1,625 @@
use serde::Deserialize;
use std::{env, fmt};
use serde::{Deserialize, Serialize};
use std::env;
use std::path::PathBuf;
use toml::Value;
use tracing::{error, info};
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(default)]
pub struct ConfigFile {
pub server: Server,
pub display: ConfDisplay,
pub analytics: Analytics,
pub filtering: Filtering,
pub sharing: Sharing,
pub ai: Ai,
pub scraping: Scraping,
pub processing: Processing,
pub migration: ConfMigration,
pub cli: Cli,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum DefaultView {
Compact,
Full,
Summary,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum Theme {
Light,
Dark,
Auto,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum DefaultSort {
#[serde(rename = "added_desc")]
AddedDesc,
#[serde(rename = "published_desc")]
PublishedDesc,
#[serde(rename = "title_asc")]
TitleAsc,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum DefaultFormat {
#[serde(rename = "text")]
Text,
#[serde(rename = "markdown")]
Markdown,
#[serde(rename = "json")]
JSON,
#[serde(rename = "html")]
HTML,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum AiProvider {
#[serde(rename = "ollama")]
Ollama,
#[serde(rename = "openai")]
OpenAi,
#[serde(rename = "anthropic")]
Anthropic,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub enum DefaultOutput {
#[serde(rename = "table")]
Table,
#[serde(rename = "json")]
JSON,
#[serde(rename = "csv")]
CSV,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Server {
pub host: String,
pub port: u16,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ConfDisplay {
pub default_view: DefaultView,
pub articles_per_page: u32,
pub show_reading_time: bool,
pub show_word_count: bool,
pub highlight_unread: bool,
pub theme: Theme,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Analytics {
pub enabled: bool,
pub track_reading_time: bool,
pub track_scroll_position: bool,
pub retention_days: u32,
pub aggregate_older_data: bool,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Filtering {
pub enable_smart_suggestions: bool,
pub max_recent_filters: u32,
pub auto_save_filters: bool,
pub default_sort: DefaultSort,
pub enable_geographic_hierarchy: bool,
pub auto_migrate_country_filters: bool,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Sharing {
pub default_format: DefaultFormat,
pub include_summary: bool,
pub include_tags: bool,
pub include_source: bool,
pub copy_to_clipboard: bool,
pub templates: SharingTemplates,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SharingTemplates {
pub text: SharingTemplate,
pub markdown: SharingTemplate,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct SharingTemplate {
pub format: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Ai {
pub enabled: bool,
pub provider: AiProvider,
pub timeout_seconds: u32,
pub summary: AiSummary,
pub tagging: AiTagging,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct AiSummary {
pub enabled: bool,
pub temperature: f32,
pub max_tokens: u32,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct AiTagging {
pub enabled: bool,
pub temperature: f32,
pub max_tokens: u32,
pub max_tags_per_article: u32,
pub min_confidence_threshold: f32,
pub enable_geographic_tagging: bool,
pub enable_category_tagging: bool,
pub geographic_hierarchy_levels: u32,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Scraping {
pub timeout_seconds: u32,
pub max_retries: u32,
pub max_content_length: u32,
pub respect_robots_txt: bool,
pub rate_limit_delay_ms: u32,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Processing {
pub batch_size: u32,
pub max_concurrent: u32,
pub retry_attempts: u32,
pub priority_manual: bool,
pub auto_mark_read_on_view: bool,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ConfMigration {
pub auto_convert_country_filters: bool,
pub preserve_legacy_data: bool,
pub migration_batch_size: u32,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Cli {
pub default_output: DefaultOutput,
pub pager_command: String,
pub show_progress: bool,
pub auto_confirm_bulk: bool,
pub show_geographic_hierarchy: bool,
}
impl DefaultView {
pub fn display_name(&self) -> &str {
match self {
DefaultView::Compact => "Compact",
DefaultView::Full => "Full Article",
DefaultView::Summary => "Summary",
}
}
pub fn from_string(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"compact" => Some(DefaultView::Compact),
"full" => Some(DefaultView::Full),
"summary" => Some(DefaultView::Summary),
_ => None,
}
}
}
impl Default for DefaultView {
fn default() -> Self {
DefaultView::Compact
}
}
impl Theme {
pub fn display_name(&self) -> &str {
match self {
Theme::Light => "Light Mode",
Theme::Dark => "Dark Mode",
Theme::Auto => "Auto (System)",
}
}
pub fn is_dark_mode(&self, system_prefers_dark: bool) -> bool {
match self {
Theme::Light => false,
Theme::Dark => true,
Theme::Auto => system_prefers_dark,
}
}
}
impl Default for Theme {
fn default() -> Self {
Theme::Auto
}
}
impl DefaultSort {
pub fn display_name(&self) -> &str {
match self {
DefaultSort::AddedDesc => "Recently Added",
DefaultSort::PublishedDesc => "Recently Published",
DefaultSort::TitleAsc => "Title A-Z",
}
}
pub fn to_sql_order(&self) -> &str {
match self {
DefaultSort::AddedDesc => "added_at DESC",
DefaultSort::PublishedDesc => "published_at DESC",
DefaultSort::TitleAsc => "title ASC",
}
}
}
impl Default for DefaultSort {
fn default() -> Self {
DefaultSort::AddedDesc
}
}
impl DefaultFormat {
pub fn display_name(&self) -> &str {
match self {
DefaultFormat::Text => "Plain Text",
DefaultFormat::Markdown => "Markdown",
DefaultFormat::JSON => "JSON",
DefaultFormat::HTML => "HTML",
}
}
pub fn mime_type(&self) -> &str {
match self {
DefaultFormat::Text => "text/plain",
DefaultFormat::Markdown => "text/markdown",
DefaultFormat::JSON => "application/json",
DefaultFormat::HTML => "text/html",
}
}
}
impl Default for DefaultFormat {
fn default() -> Self {
DefaultFormat::Text
}
}
impl AiProvider {
pub fn display_name(&self) -> &str {
match self {
AiProvider::Ollama => "Ollama",
AiProvider::OpenAi => "OpenAI",
AiProvider::Anthropic => "Anthropic",
}
}
}
impl Default for AiProvider {
fn default() -> Self {
AiProvider::Ollama
}
}
impl DefaultOutput {
pub fn display_name(&self) -> &str {
match self {
DefaultOutput::Table => "Table",
DefaultOutput::JSON => "JSON",
DefaultOutput::CSV => "CSV",
}
}
}
impl Default for DefaultOutput {
fn default() -> Self {
DefaultOutput::Table
}
}
impl Default for Cli {
fn default() -> Self {
Self {
default_output: DefaultOutput::Table,
pager_command: "less -R".to_string(),
show_progress: true,
auto_confirm_bulk: false,
show_geographic_hierarchy: true,
}
}
}
impl Default for ConfMigration {
fn default() -> Self {
Self {
auto_convert_country_filters: true,
preserve_legacy_data: true,
migration_batch_size: 100,
}
}
}
impl Default for Processing {
fn default() -> Self {
Self {
batch_size: 10,
max_concurrent: 5,
retry_attempts: 3,
priority_manual: true,
auto_mark_read_on_view: false,
}
}
}
impl Default for Scraping {
fn default() -> Self {
Self {
timeout_seconds: 30,
max_retries: 3,
max_content_length: 100_000,
respect_robots_txt: true,
rate_limit_delay_ms: 1000,
}
}
}
impl Default for Ai {
fn default() -> Self {
Self {
enabled: true,
provider: AiProvider::default(),
timeout_seconds: 120,
summary: AiSummary::default(),
tagging: AiTagging::default(),
}
}
}
impl Default for AiSummary {
fn default() -> Self {
Self {
enabled: true,
temperature: 0.1,
max_tokens: 1024,
}
}
}
impl Default for AiTagging {
fn default() -> Self {
Self {
enabled: true,
temperature: 0.3,
max_tokens: 200,
max_tags_per_article: 10,
min_confidence_threshold: 0.7,
enable_geographic_tagging: true,
enable_category_tagging: true,
geographic_hierarchy_levels: 3,
}
}
}
impl Default for Sharing {
fn default() -> Self {
Self {
default_format: DefaultFormat::default(),
include_summary: true,
include_tags: true,
include_source: true,
copy_to_clipboard: true,
templates: SharingTemplates::default(),
}
}
}
impl Default for SharingTemplates {
fn default() -> Self {
Self {
text: SharingTemplate {
format: r#"📰 {title}
{summary}
🏷️ Tags: {tags}
🌍 Location: {geographic_tags}
🔗 Source: {url}
📅 Published: {published_at}
Shared via Owly News Summariser
"#
.to_string(),
},
markdown: SharingTemplate {
format: r#"# {title}
{summary}
**Tags:** {tags}
**Location:** {geographic_tags}
**Source:** [{url}]({url})
**Published:** {published_at}
---
*Shared via Owly News Summariser*
"#
.to_string(),
},
}
}
}
impl Default for Filtering {
fn default() -> Self {
Self {
enable_smart_suggestions: true,
max_recent_filters: 10,
auto_save_filters: true,
default_sort: DefaultSort::AddedDesc,
enable_geographic_hierarchy: true,
auto_migrate_country_filters: true,
}
}
}
impl Default for Analytics {
fn default() -> Self {
Self {
enabled: true,
track_reading_time: true,
track_scroll_position: true,
retention_days: 365,
aggregate_older_data: true,
}
}
}
impl Default for ConfDisplay {
fn default() -> Self {
Self {
default_view: DefaultView::default(),
articles_per_page: 50,
show_reading_time: true,
show_word_count: false,
highlight_unread: true,
theme: Theme::default(),
}
}
}
impl Default for Server {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 8090,
}
}
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
server: Server::default(),
display: ConfDisplay::default(),
analytics: Analytics::default(),
filtering: Filtering::default(),
sharing: Sharing::default(),
ai: Ai::default(),
scraping: Scraping::default(),
processing: Processing::default(),
migration: ConfMigration::default(),
cli: Cli::default(),
}
}
}
impl ConfigFile {
pub fn load_from_file(app_settings: &AppSettings) -> Result<Self, Box<dyn std::error::Error>> {
let config_path_str = &app_settings.config_path;
let config_path = PathBuf::from(config_path_str);
if !config_path.exists() {
let config_file = ConfigFile::default();
config_file.save_to_file(app_settings)?;
return Ok(config_file);
}
let contents = std::fs::read_to_string(config_path_str)?;
let config: ConfigFile = toml::from_str(&contents)?;
Ok(config)
}
pub fn save_to_file(
&self,
app_settings: &AppSettings,
) -> Result<(), Box<dyn std::error::Error>> {
let contents = toml::to_string_pretty(self)?;
std::fs::write(&app_settings.config_path, contents)?;
Ok(())
}
}
#[derive(Deserialize, Debug)]
pub struct AppSettings {
pub config_path: String,
pub db_path: String,
pub migration_path: String,
pub config: Config,
pub config: ConfigFile,
}
#[derive(Deserialize, Debug)]
pub struct Config {
pub server: Server,
}
#[derive(Deserialize, Debug)]
pub struct Server {
pub host: String,
pub port: u16,
}
#[derive(Deserialize, Debug)]
struct ConfigFile {
server: Server,
}
#[derive(Debug)]
pub enum ConfigError {
InvalidPort,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::InvalidPort => write!(f, "Invalid port: port cannot be 0"),
}
}
}
impl std::error::Error for ConfigError {}
impl AppSettings {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.config.server.port == 0 {
return Err(ConfigError::InvalidPort);
}
Ok(())
}
pub fn get_app_settings() -> Self {
let config_file = Self::load_config_file().unwrap_or_else(|| {
info!("Using default config values");
ConfigFile {
server: Server {
host: "127.0.0.1".to_string(),
port: 1337,
},
}
});
impl Default for AppSettings {
fn default() -> Self {
Self {
config_path: Self::get_config_path(),
db_path: Self::get_db_path(),
migration_path: String::from("./migrations"),
config: Config {
server: config_file.server,
},
migration_path: Self::get_migration_path(),
config: ConfigFile::default(),
}
}
}
fn load_config_file() -> Option<ConfigFile> {
let config_path = Self::get_config_path();
let contents = std::fs::read_to_string(&config_path)
.map_err(|e| error!("Failed to read config file: {}", e))
.ok()?;
impl AppSettings {
const FALLBACK_DIR: &'static str = "/tmp";
const PROD_DB_PATH: &'static str = "/var/lib/owly-news/owlynews.sqlite3";
const PROD_MIGRATION_PATH: &'static str = "/usr/share/owly-news/migrations";
toml::from_str(&contents)
.map_err(|e| error!("Failed to parse TOML: {}", e))
.ok()
pub fn get_app_settings() -> Self {
AppSettings::default()
}
fn get_base_path(env_var: &str, fallback: &str) -> String {
env::var(env_var).unwrap_or_else(|_| fallback.to_string())
}
fn get_db_path() -> String {
if cfg!(debug_assertions) {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("owlynews.sqlite3");
path.to_str()
.ok_or_else(|| anyhow::anyhow!("Failed to convert path to string"))
.ok()
.expect("Failed to convert path to string")
.to_string()
let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR);
format!("{}/owlynews.sqlite3", manifest_dir)
} else {
// Production: Use standard Linux applications data directory
"/var/lib/owly-news/owlynews.sqlite3".to_string()
Self::PROD_DB_PATH.to_string()
}
}
fn get_config_path() -> String {
if cfg!(debug_assertions) {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("config.toml");
path.to_str()
.ok_or_else(|| anyhow::anyhow!("Failed to convert path to string"))
.ok()
.expect("Failed to convert path to string")
.to_string()
let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR);
format!("{}/config.toml", manifest_dir)
} else {
// Production: Use standard Linux applications data directory
let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let home = Self::get_base_path("HOME", Self::FALLBACK_DIR);
format!("{}/.config/owly-news/config.toml", home)
}
}
fn get_migration_path() -> String {
if cfg!(debug_assertions) {
let manifest_dir = Self::get_base_path("CARGO_MANIFEST_DIR", Self::FALLBACK_DIR);
format!("{}/migrations", manifest_dir)
} else {
Self::PROD_MIGRATION_PATH.to_string()
}
}
pub fn database_url(&self) -> String {
format!("sqlite:{}", self.db_path)
}
pub fn ensure_db_directory(&self) -> Result<(), std::io::Error> {
pub fn ensure_default_directory(&self) -> Result<(), std::io::Error> {
// Always create the database directory
if let Some(parent) = std::path::Path::new(&self.db_path).parent() {
std::fs::create_dir_all(parent)?;
}
// In production mode, also create the config directory
if !cfg!(debug_assertions) {
if let Some(parent) = std::path::Path::new(&self.config_path).parent() {
std::fs::create_dir_all(parent)?;
}
}
Ok(())
}
}

View File

@@ -10,7 +10,7 @@ use tracing::info;
pub const MIGRATOR: Migrator = sqlx::migrate!("./migrations");
pub async fn initialize_db(app_settings: &AppSettings) -> Result<Pool<Sqlite>> {
app_settings.ensure_db_directory()?;
app_settings.ensure_default_directory()?;
let options = SqliteConnectOptions::from_str(&app_settings.database_url())?
.create_if_missing(true)

View File

@@ -4,37 +4,48 @@ mod db;
mod models;
mod services;
use crate::config::{AppSettings};
use crate::config::{AppSettings, ConfigFile};
use anyhow::Result;
use axum::Router;
use axum::routing::get;
use tokio::signal;
use tracing::{info};
use tracing::info;
use tracing_subscriber;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_target(false)
.compact()
.with_env_filter(EnvFilter::from_default_env())
.json() // For production
.init();
let app_settings = AppSettings::get_app_settings();
info!("Starting server");
AppSettings::default();
let mut app_settings = AppSettings::get_app_settings();
AppSettings::ensure_default_directory(&app_settings)
.expect("Failed to create default directory");
app_settings.config = ConfigFile::load_from_file(&AppSettings::get_app_settings())
.expect("Failed to load config file");
let pool = db::initialize_db(&app_settings).await?;
let app = create_app(pool);
let listener =
tokio::net::TcpListener::bind(format!("{}:{}", app_settings.config.server.host, app_settings.config.server.port)).await?;
info!("Server starting on {}:{}", app_settings.config.server.host, app_settings.config.server.port);
let listener = tokio::net::TcpListener::bind(format!(
"{}:{}",
&app_settings.config.server.host, &app_settings.config.server.port
))
.await?;
info!(
"Server starting on http://{}:{}",
&app_settings.config.server.host, &app_settings.config.server.port
);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
info!("Server stopped");
Ok(())
}