use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::error::{Result, YouTubeError};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub output_dir: Option<String>,
#[serde(default)]
pub sync_channels: bool,
#[serde(default)]
pub feed: Vec<Feed>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Feed {
pub name: String,
#[serde(default)]
pub videoids: Vec<String>,
#[serde(default)]
pub sync_channels: Option<bool>,
}
impl Feed {
pub fn should_sync_channels(&self, global_default: bool) -> bool {
self.sync_channels.unwrap_or(global_default)
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn from_str(content: &str) -> Result<Self> {
let config: Config = toml::from_str(content)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.feed.is_empty() {
return Err(YouTubeError::Config(
"At least one [[feed]] section is required".to_string(),
));
}
for feed in &self.feed {
if feed.name.is_empty() {
return Err(YouTubeError::Config(
"Feed name cannot be empty".to_string(),
));
}
if feed.name.contains('/') || feed.name.contains('\\') {
return Err(YouTubeError::Config(format!(
"Feed name cannot contain path separators: {}",
feed.name
)));
}
for video_id in &feed.videoids {
if video_id.len() != 11 {
return Err(YouTubeError::Config(format!(
"Invalid video ID length for '{}': expected 11 characters, got {}",
video_id,
video_id.len()
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_config() {
let toml = r#"
output_dir = "./data/youtube"
sync_channels = true
[[feed]]
name = "2019_python_automate-boring-stuff"
videoids = [
"OVNXs2U_ckc",
"iXrW-5Sf3ys",
"PO0HfsSRGkg"
]
[[feed]]
name = "2024_rust_the-rust-programming-language-book"
videoids = [
"4U3_FakJ_Pg",
"7Eq0h_dvUGE",
"sHuy19Oqus0"
]
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.output_dir, Some("./data/youtube".to_string()));
assert!(config.sync_channels);
assert_eq!(config.feed.len(), 2);
assert_eq!(config.feed[0].name, "2019_python_automate-boring-stuff");
assert_eq!(config.feed[0].videoids.len(), 3);
assert_eq!(
config.feed[1].name,
"2024_rust_the-rust-programming-language-book"
);
}
#[test]
fn test_parse_minimal_config() {
let toml = r#"
[[feed]]
name = "test-feed"
videoids = ["OVNXs2U_ckc"]
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.output_dir, None);
assert!(!config.sync_channels);
assert_eq!(config.feed.len(), 1);
}
#[test]
fn test_empty_feeds_fails() {
let toml = r#"
output_dir = "./data"
"#;
let result = Config::from_str(toml);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, YouTubeError::Config(_)));
}
#[test]
fn test_empty_feed_name_fails() {
let toml = r#"
[[feed]]
name = ""
videoids = ["OVNXs2U_ckc"]
"#;
let result = Config::from_str(toml);
assert!(result.is_err());
}
#[test]
fn test_feed_name_with_slash_fails() {
let toml = r#"
[[feed]]
name = "path/to/feed"
videoids = ["OVNXs2U_ckc"]
"#;
let result = Config::from_str(toml);
assert!(result.is_err());
}
#[test]
fn test_feed_name_with_backslash_fails() {
let toml = r#"
[[feed]]
name = "path\\to\\feed"
videoids = ["OVNXs2U_ckc"]
"#;
let result = Config::from_str(toml);
assert!(result.is_err());
}
#[test]
fn test_invalid_video_id_length_fails() {
let toml = r#"
[[feed]]
name = "test-feed"
videoids = ["short"]
"#;
let result = Config::from_str(toml);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
YouTubeError::Config(msg) => {
assert!(msg.contains("Invalid video ID length"));
assert!(msg.contains("short"));
}
_ => panic!("Expected Config error"),
}
}
#[test]
fn test_valid_video_ids_from_real_config() {
let toml = r#"
[[feed]]
name = "2018_javascript_eloquent-javascript"
videoids = [
"Qpl1fh_Py2s",
"Y6nUGuoI0sQ",
"rkPUX5xXsC0",
"hKWBxkXWMVs",
"PK2rB9VGWSA",
"QQguWy4aX2w",
"Se6Oxyl03xE",
"xRgWMhbVlpQ",
"Syp_QRmsKkI"
]
"#;
let config = Config::from_str(toml).unwrap();
assert_eq!(config.feed[0].videoids.len(), 9);
}
#[test]
fn test_feed_with_empty_videoids() {
let toml = r#"
[[feed]]
name = "empty-feed"
videoids = []
"#;
let config = Config::from_str(toml).unwrap();
assert!(config.feed[0].videoids.is_empty());
}
#[test]
fn test_should_sync_channels_uses_global_default() {
let feed = Feed {
name: "test".to_string(),
videoids: vec![],
sync_channels: None,
};
assert!(!feed.should_sync_channels(false));
assert!(feed.should_sync_channels(true));
}
#[test]
fn test_should_sync_channels_override_takes_precedence() {
let feed_enabled = Feed {
name: "test".to_string(),
videoids: vec![],
sync_channels: Some(true),
};
let feed_disabled = Feed {
name: "test".to_string(),
videoids: vec![],
sync_channels: Some(false),
};
assert!(feed_enabled.should_sync_channels(false));
assert!(feed_enabled.should_sync_channels(true));
assert!(!feed_disabled.should_sync_channels(false));
assert!(!feed_disabled.should_sync_channels(true));
}
#[test]
fn test_parse_sync_channels_per_feed() {
let toml = r#"
sync_channels = false
[[feed]]
name = "feed-with-override"
videoids = ["OVNXs2U_ckc"]
sync_channels = true
[[feed]]
name = "feed-without-override"
videoids = ["iXrW-5Sf3ys"]
"#;
let config = Config::from_str(toml).unwrap();
assert!(!config.sync_channels);
assert_eq!(config.feed[0].sync_channels, Some(true));
assert_eq!(config.feed[1].sync_channels, None);
assert!(config.feed[0].should_sync_channels(config.sync_channels));
assert!(!config.feed[1].should_sync_channels(config.sync_channels));
}
}