youtubeinfo-sync 1.0.2

Download YouTube video and channel metadata
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>,

    /// Global default for sync_channels (defaults to false)
    #[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>,

    /// Per-feed override for sync_channels
    #[serde(default)]
    pub sync_channels: Option<bool>,
}

impl Feed {
    /// Resolve sync_channels with per-feed override taking precedence
    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)
    }

    /// Parse config from a TOML string (useful for testing)
    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
                )));
            }

            // Validate video ID format (11 characters)
            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() {
        // Video IDs from the provided youtube.toml
        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),
        };

        // Per-feed override should take precedence over global
        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);

        // Test resolution
        assert!(config.feed[0].should_sync_channels(config.sync_channels));
        assert!(!config.feed[1].should_sync_channels(config.sync_channels));
    }
}