From 4706de1b4287eaf72b22f35e9dd6074bf573448c Mon Sep 17 00:00:00 2001 From: mace Date: Thu, 14 May 2026 18:31:13 +0200 Subject: [PATCH] added cloude generated tests --- .claude/settings.local.json | 7 + CLAUDE.md | 50 ++++ src/cli/command.rs | 90 ++++++++ src/cli/controller.rs | 46 ++-- src/container.rs | 10 +- src/database/db.rs | 25 +- src/entity/file.rs | 2 +- src/entity/filter.rs | 2 +- src/entity/filter_config.rs | 2 +- src/entity/filter_log.rs | 2 +- src/entity/filter_uses.rs | 2 +- src/entity/job.rs | 4 +- src/entity/merchant.rs | 2 +- src/entity/schema.rs | 2 +- src/lib.rs | 21 ++ src/repository/file_repo.rs | 10 + src/repository/filter_repo.rs | 22 ++ src/repository/job_repo.rs | 14 ++ src/repository/merchant_repo.rs | 10 + src/repository/schema_repo.rs | 10 + src/service/file_service.rs | 79 +++++-- src/service/filter_service.rs | 391 +++++++++++++++++++++++++++----- src/service/job_service.rs | 119 ++++++++-- src/service/merchant_service.rs | 74 ++++-- src/service/schema_service.rs | 86 +++++-- 25 files changed, 926 insertions(+), 156 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..095795f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo test *)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44ca36b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +cargo build # debug build +cargo build --release # release build +cargo run -- # run with args (see CLI section) +cargo check # fast type/borrow check without linking +cargo clippy # lint +``` + +No tests exist yet in this project. + +## Configuration + +The binary reads environment config from `~/.config/rcc/` (dotenv format). Default env is `local`. + +Required keys: `DB_USERNAME`, `DB_PASSWORD` +Optional keys: `DB_LOCATION` (default: localhost), `DB_PORT` (default: 3306), `DB` (default: efulfilment) + +Pass `-e ` to select a config file (e.g., `-e stage` loads `~/.config/rcc/stage`). + +## Architecture + +Three-layer structure: **CLI → Service → Repository**, wired together in `container.rs`. + +- `src/cli/command.rs` — Clap structs defining all subcommands (`Merch`, `Schema`, `Filter`, `Page`, `Job`, `Use`) +- `src/cli/controller.rs` — dispatches parsed args to the appropriate service +- `src/container.rs` — factory functions that construct `Repo → Service` pairs from a `Db` instance +- `src/database/db.rs` — `Db` wraps a `Arc>` (mysql); `Db::initialize(env)` loads the dotenv config and opens a connection pool +- `src/repository/` — one repo per domain entity; each holds a `Db` and executes raw MySQL queries +- `src/service/` — one service per domain; holds the corresponding repo, formats and prints results to stdout +- `src/entity/` — plain structs with `mysql::FromRow` derives representing DB rows +- `src/lib.rs` — exports the `TerminalSize` trait (used by services to adapt output width to the terminal) + +## CLI Usage + +``` +rcc [-e ] + +merch # search merchants by name +schema # list columns for a table +filter [-a] [-c] [-l] # filter info; -a=all, -c=config, -l=log +page # page/file info +job [-l] # job info; -l=log +use # find filters that use a given file/module +``` diff --git a/src/cli/command.rs b/src/cli/command.rs index 6a147a1..085ecec 100644 --- a/src/cli/command.rs +++ b/src/cli/command.rs @@ -48,3 +48,93 @@ pub enum Commands { file_name: String, } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(args).expect("parse failed") + } + + #[test] + fn merch_command() { + let cli = parse(&["rcc", "merch", "acme"]); + assert!(matches!(cli.mode, Commands::Merch { search: Some(ref s) } if s == "acme")); + } + + #[test] + fn schema_command() { + let cli = parse(&["rcc", "schema", "order_id"]); + assert!(matches!(cli.mode, Commands::Schema { search: Some(ref s) } if s == "order_id")); + } + + #[test] + fn filter_command_defaults() { + let cli = parse(&["rcc", "filter", "42"]); + assert!(matches!( + cli.mode, + Commands::Filter { filter_id: 42, all: false, config: false, log: false } + )); + } + + #[test] + fn filter_command_all_flag() { + let cli = parse(&["rcc", "filter", "42", "--all"]); + assert!(matches!(cli.mode, Commands::Filter { all: true, .. })); + } + + #[test] + fn filter_command_config_flag() { + let cli = parse(&["rcc", "filter", "42", "--config"]); + assert!(matches!(cli.mode, Commands::Filter { config: true, .. })); + } + + #[test] + fn filter_command_log_flag() { + let cli = parse(&["rcc", "filter", "42", "--log"]); + assert!(matches!(cli.mode, Commands::Filter { log: true, .. })); + } + + #[test] + fn page_command() { + let cli = parse(&["rcc", "page", "7"]); + assert!(matches!(cli.mode, Commands::Page { page_id: 7 })); + } + + #[test] + fn job_command_default() { + let cli = parse(&["rcc", "job", "3"]); + assert!(matches!(cli.mode, Commands::Job { job_id: 3, log: false })); + } + + #[test] + fn job_command_log_flag() { + let cli = parse(&["rcc", "job", "3", "--log"]); + assert!(matches!(cli.mode, Commands::Job { job_id: 3, log: true })); + } + + #[test] + fn use_command() { + let cli = parse(&["rcc", "use", "my_module.php"]); + assert!(matches!(cli.mode, Commands::Use { ref file_name } if file_name == "my_module.php")); + } + + #[test] + fn env_flag_is_captured() { + let cli = parse(&["rcc", "-e", "stage", "merch", "x"]); + assert_eq!(cli.env.as_deref(), Some("stage")); + } + + #[test] + fn env_defaults_to_none_when_not_provided() { + let cli = parse(&["rcc", "merch", "x"]); + assert!(cli.env.is_none()); + } + + #[test] + fn unknown_subcommand_is_rejected() { + assert!(Cli::try_parse_from(["rcc", "bogus"]).is_err()); + } +} diff --git a/src/cli/controller.rs b/src/cli/controller.rs index 20770bb..6d2dccb 100644 --- a/src/cli/controller.rs +++ b/src/cli/controller.rs @@ -1,11 +1,6 @@ -use crate::{ - container, - database::db::Db, - service::{ - file_service::FileService, filter_service::FilterService, job_service::JobService, - merchant_service::MerchantService, schema_service::SchemaService, - }, -}; +use std::io; + +use crate::{container, database::db::Db}; use super::command::{Cli, Commands}; @@ -18,14 +13,17 @@ enum CommandError { } pub fn process_args(args: Cli, db: Db) -> Result<(), Box> { + let stdout = io::stdout(); + let mut out = stdout.lock(); + match args.mode { Commands::Merch { search } => { - let mut merchant_service: MerchantService = container::get_merchant_service(db); - merchant_service.get_merchant(&search.ok_or(CommandError::EmptySearch())?)?; + let mut merchant_service = container::get_merchant_service(db); + merchant_service.get_merchant(&search.ok_or(CommandError::EmptySearch())?, &mut out)?; } Commands::Schema { search } => { - let mut schema_service: SchemaService = container::get_schema_service(db); - schema_service.get_columns(&search.ok_or(CommandError::EmptySearch())?)?; + let mut schema_service = container::get_schema_service(db); + schema_service.get_columns(&search.ok_or(CommandError::EmptySearch())?, &mut out)?; } Commands::Filter { filter_id, @@ -33,31 +31,31 @@ pub fn process_args(args: Cli, db: Db) -> Result<(), Box> all, log, } => { - let mut filter_service: FilterService = container::get_filter_service(db); + let mut filter_service = container::get_filter_service(db); if config { - filter_service.get_filter_configs(&filter_id)?; + filter_service.get_filter_configs(&filter_id, &mut out)?; } else if log { - filter_service.get_filter_log(&filter_id, all)?; + filter_service.get_filter_log(&filter_id, all, &mut out)?; } else { - filter_service.get_filter(&filter_id, all)?; + filter_service.get_filter(&filter_id, all, &mut out)?; } } Commands::Page { page_id } => { - let mut file_service: FileService = container::get_page_service(db); - file_service.get_page(&page_id)?; + let mut file_service = container::get_page_service(db); + file_service.get_page(&page_id, &mut out)?; } Commands::Job { job_id, log } => { - let mut job_service: JobService = container::get_job_service(db); + let mut job_service = container::get_job_service(db); if log { - job_service.get_job_log(&job_id)?; + job_service.get_job_log(&job_id, &mut out)?; } else { - job_service.get_job_by_id(&job_id)?; + job_service.get_job_by_id(&job_id, &mut out)?; } } Commands::Use { file_name } => { - let mut filter_service: FilterService = container::get_filter_service(db); - filter_service.get_filter_uses(&file_name)?; - }, + let mut filter_service = container::get_filter_service(db); + filter_service.get_filter_uses(&file_name, &mut out)?; + } } Ok(()) diff --git a/src/container.rs b/src/container.rs index 9adf4be..b09535c 100644 --- a/src/container.rs +++ b/src/container.rs @@ -10,27 +10,27 @@ use crate::{ }, }; -pub fn get_filter_service(pool: Db) -> FilterService { +pub fn get_filter_service(pool: Db) -> FilterService { let repo = FilterRepo::new(pool); FilterService::new(repo) } -pub fn get_merchant_service(pool: Db) -> MerchantService { +pub fn get_merchant_service(pool: Db) -> MerchantService { let repo = MerchantRepo::new(pool); MerchantService::new(repo) } -pub fn get_schema_service(pool: Db) -> SchemaService { +pub fn get_schema_service(pool: Db) -> SchemaService { let repo = SchemaRepo::new(pool); SchemaService::new(repo) } -pub fn get_page_service(pool: Db) -> FileService { +pub fn get_page_service(pool: Db) -> FileService { let repo = FileRepo::new(pool); FileService::new(repo) } -pub(crate) fn get_job_service(pool: Db) -> JobService { +pub fn get_job_service(pool: Db) -> JobService { let repo = JobRepo::new(pool); JobService::new(repo) } diff --git a/src/database/db.rs b/src/database/db.rs index b7930aa..c0f2697 100644 --- a/src/database/db.rs +++ b/src/database/db.rs @@ -69,13 +69,26 @@ impl Db { impl Drop for Db { fn drop(&mut self) { - // The drop function is called when the Db instance goes out of scope - // This is where you can clean up resources, like returning connections to the pool log::info!("Db instance is being dropped. Cleaning up resources."); - - // You might want to explicitly drop the Mutex guard to release the lock - // (This is not strictly necessary as the Mutex will be automatically dropped, - // but it can be useful for explicitness) drop(self.pool.lock().unwrap()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initialize_fails_when_config_file_missing() { + let result = Db::initialize("__nonexistent_env__".to_string()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("__nonexistent_env__"), "error should mention the env name: {msg}"); + } + + #[test] + fn initialize_fails_for_empty_env_name() { + let result = Db::initialize(String::new()); + assert!(result.is_err()); + } +} diff --git a/src/entity/file.rs b/src/entity/file.rs index 5b3e64a..a6af869 100644 --- a/src/entity/file.rs +++ b/src/entity/file.rs @@ -1,4 +1,4 @@ -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Page { pub m_id: usize, pub title: String, diff --git a/src/entity/filter.rs b/src/entity/filter.rs index 70019a1..f92821c 100644 --- a/src/entity/filter.rs +++ b/src/entity/filter.rs @@ -1,4 +1,4 @@ -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Filter { pub filter_module_id: usize, pub file_name: String, diff --git a/src/entity/filter_config.rs b/src/entity/filter_config.rs index c849dc5..6b59c0c 100644 --- a/src/entity/filter_config.rs +++ b/src/entity/filter_config.rs @@ -1,5 +1,5 @@ use time::PrimitiveDateTime; -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct FilterConfig { pub attribute: String, pub value1: String, diff --git a/src/entity/filter_log.rs b/src/entity/filter_log.rs index 5e459de..9e631b0 100644 --- a/src/entity/filter_log.rs +++ b/src/entity/filter_log.rs @@ -1,6 +1,6 @@ use time::PrimitiveDateTime; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FilterLog { pub run_ts: PrimitiveDateTime, pub error_code: String, diff --git a/src/entity/filter_uses.rs b/src/entity/filter_uses.rs index d19fead..d409262 100644 --- a/src/entity/filter_uses.rs +++ b/src/entity/filter_uses.rs @@ -1,4 +1,4 @@ -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FilterUses { pub filter_module_id: usize, pub description: String, diff --git a/src/entity/job.rs b/src/entity/job.rs index 3742e91..d431e5d 100644 --- a/src/entity/job.rs +++ b/src/entity/job.rs @@ -1,12 +1,12 @@ use time::PrimitiveDateTime; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Job { pub file_name: String, pub description: String, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct JobLog { pub content: String, pub upd_ts: PrimitiveDateTime, diff --git a/src/entity/merchant.rs b/src/entity/merchant.rs index 077ee6d..457d5db 100644 --- a/src/entity/merchant.rs +++ b/src/entity/merchant.rs @@ -1,5 +1,5 @@ -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Merchant { pub m_id: usize, pub m_name: String, diff --git a/src/entity/schema.rs b/src/entity/schema.rs index 99bf0dd..ce27e0f 100644 --- a/src/entity/schema.rs +++ b/src/entity/schema.rs @@ -1,5 +1,5 @@ -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Schema { pub table_name: String, pub column_name: String, diff --git a/src/lib.rs b/src/lib.rs index 1197726..6f05ca8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,24 @@ pub trait TerminalSize { terminal.cols.into() } } + +#[cfg(test)] +mod tests { + use super::*; + + struct Probe; + impl TerminalSize for Probe {} + + #[test] + fn get_width_returns_positive_value() { + assert!(Probe.get_width() > 0); + } + + #[test] + fn get_width_falls_back_to_150_when_no_tty() { + // In a non-TTY test environment termsize::get() returns None, + // so the fallback of 150 columns is used. + let w = Probe.get_width(); + assert!(w >= 1); + } +} diff --git a/src/repository/file_repo.rs b/src/repository/file_repo.rs index 55623d8..362093b 100644 --- a/src/repository/file_repo.rs +++ b/src/repository/file_repo.rs @@ -2,11 +2,21 @@ use mysql::{params, prelude::Queryable}; use crate::{database::db::Db, entity::file::Page}; +pub trait FileRepoTrait { + fn find_page(&mut self, file_id: &usize) -> Result, mysql::Error>; +} + #[derive(Debug)] pub struct FileRepo { db_pool: Db, } +impl FileRepoTrait for FileRepo { + fn find_page(&mut self, file_id: &usize) -> Result, mysql::Error> { + self.find_page(file_id) + } +} + impl FileRepo { pub fn new(db: Db) -> Self { Self { db_pool: db } diff --git a/src/repository/filter_repo.rs b/src/repository/filter_repo.rs index d3b5efe..b87f043 100644 --- a/src/repository/filter_repo.rs +++ b/src/repository/filter_repo.rs @@ -9,11 +9,33 @@ use crate::{ }, }; +pub trait FilterRepoTrait { + fn find_by_id(&mut self, filter_id: &usize) -> Result, mysql::Error>; + fn find_filter_configs(&mut self, filter_id: &usize) -> Result, mysql::Error>; + fn find_filter_log(&mut self, filter_id: &usize) -> Result, mysql::Error>; + fn find_filter_uses(&mut self, filter_file: &str) -> Result, mysql::Error>; +} + #[derive(Debug)] pub struct FilterRepo { db: Db, } +impl FilterRepoTrait for FilterRepo { + fn find_by_id(&mut self, filter_id: &usize) -> Result, mysql::Error> { + self.find_by_id(filter_id) + } + fn find_filter_configs(&mut self, filter_id: &usize) -> Result, mysql::Error> { + self.find_filter_configs(filter_id) + } + fn find_filter_log(&mut self, filter_id: &usize) -> Result, mysql::Error> { + self.find_filter_log(filter_id) + } + fn find_filter_uses(&mut self, filter_file: &str) -> Result, mysql::Error> { + self.find_filter_uses(filter_file) + } +} + impl FilterRepo { pub fn new(db: Db) -> Self { Self { db } diff --git a/src/repository/job_repo.rs b/src/repository/job_repo.rs index 2a326e9..a3054b3 100644 --- a/src/repository/job_repo.rs +++ b/src/repository/job_repo.rs @@ -5,11 +5,25 @@ use crate::{ entity::job::{Job, JobLog}, }; +pub trait JobRepoTrait { + fn find_by_id(&mut self, job_id: &usize) -> Result, mysql::Error>; + fn find_log(&mut self, job_id: &usize) -> Result, mysql::Error>; +} + #[derive(Debug)] pub struct JobRepo { db: Db, } +impl JobRepoTrait for JobRepo { + fn find_by_id(&mut self, job_id: &usize) -> Result, mysql::Error> { + self.find_by_id(job_id) + } + fn find_log(&mut self, job_id: &usize) -> Result, mysql::Error> { + self.find_log(job_id) + } +} + impl JobRepo { pub fn new(db: Db) -> Self { Self { db } diff --git a/src/repository/merchant_repo.rs b/src/repository/merchant_repo.rs index fce2450..9b31ea9 100644 --- a/src/repository/merchant_repo.rs +++ b/src/repository/merchant_repo.rs @@ -2,10 +2,20 @@ use mysql::{params, prelude::Queryable}; use crate::{database::db::Db, entity::merchant::Merchant}; +pub trait MerchantRepoTrait { + fn find_by_name_or_id(&mut self, search: &str) -> Result, mysql::Error>; +} + pub struct MerchantRepo { db_pool: Db, } +impl MerchantRepoTrait for MerchantRepo { + fn find_by_name_or_id(&mut self, search: &str) -> Result, mysql::Error> { + self.find_by_name_or_id(search) + } +} + impl MerchantRepo { pub fn new(db_pool: Db) -> Self { Self { db_pool } diff --git a/src/repository/schema_repo.rs b/src/repository/schema_repo.rs index 3ba97f1..8092b01 100644 --- a/src/repository/schema_repo.rs +++ b/src/repository/schema_repo.rs @@ -2,10 +2,20 @@ use mysql::{params, prelude::Queryable}; use crate::{database::db::Db, entity::schema::Schema}; +pub trait SchemaRepoTrait { + fn find_by_column(&mut self, column_name: &str) -> Result, mysql::Error>; +} + pub struct SchemaRepo { db: Db, } +impl SchemaRepoTrait for SchemaRepo { + fn find_by_column(&mut self, column_name: &str) -> Result, mysql::Error> { + self.find_by_column(column_name) + } +} + impl SchemaRepo { pub fn new(db: Db) -> Self { Self { db } diff --git a/src/service/file_service.rs b/src/service/file_service.rs index 869b8d9..4e482a7 100644 --- a/src/service/file_service.rs +++ b/src/service/file_service.rs @@ -1,32 +1,85 @@ +use std::io::Write; + use rcc::TerminalSize; -use crate::{entity::file::Page, repository::file_repo::FileRepo}; +use crate::{entity::file::Page, repository::file_repo::FileRepoTrait}; -pub struct FileService { - repo: FileRepo, +pub struct FileService { + repo: R, } -impl TerminalSize for FileService {} +impl TerminalSize for FileService {} -impl FileService { - pub fn new(repo: FileRepo) -> Self { +impl FileService { + pub fn new(repo: R) -> Self { Self { repo } } - pub fn get_page(&mut self, page_id: &usize) -> Result<(), Box> { + pub fn get_page(&mut self, page_id: &usize, out: &mut impl Write) -> Result<(), Box> { let page = self.repo.find_page(page_id)?; if page.is_empty() { - println!("No page found."); + writeln!(out, "No page found.")?; } else { page.into_iter().for_each(|page: Page| { - println!("{}", "-".repeat(self.get_width())); - println!("M-ID: {}", page.m_id); - println!("FileName: {}", page.file); - println!("Title: {}", page.title); + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "M-ID: {}", page.m_id).ok(); + writeln!(out, "FileName: {}", page.file).ok(); + writeln!(out, "Title: {}", page.title).ok(); }); - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{entity::file::Page, repository::file_repo::FileRepoTrait}; + + struct MockFileRepo { + pages: Vec, + } + + impl FileRepoTrait for MockFileRepo { + fn find_page(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.pages)) + } + } + + fn run(repo: MockFileRepo) -> String { + let mut svc = FileService::new(repo); + let mut out = Vec::new(); + svc.get_page(&1, &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn not_found_message() { + let out = run(MockFileRepo { pages: vec![] }); + assert_eq!(out, "No page found.\n"); + } + + #[test] + fn prints_page_fields() { + let out = run(MockFileRepo { + pages: vec![Page { m_id: 3, title: "Home".into(), file: "index.php".into() }], + }); + assert!(out.contains("M-ID: 3")); + assert!(out.contains("FileName: index.php")); + assert!(out.contains("Title: Home")); + } + + #[test] + fn multiple_pages_all_printed() { + let out = run(MockFileRepo { + pages: vec![ + Page { m_id: 1, title: "A".into(), file: "a.php".into() }, + Page { m_id: 2, title: "B".into(), file: "b.php".into() }, + ], + }); + assert!(out.contains("a.php")); + assert!(out.contains("b.php")); + } +} diff --git a/src/service/filter_service.rs b/src/service/filter_service.rs index 91f5727..30d8462 100644 --- a/src/service/filter_service.rs +++ b/src/service/filter_service.rs @@ -1,47 +1,43 @@ +use std::io::Write; + use colored::Colorize; use rcc::TerminalSize; use crate::{ entity::{filter::Filter, filter_config::FilterConfig, filter_log::FilterLog, filter_uses::FilterUses}, - repository::filter_repo::FilterRepo, + repository::filter_repo::FilterRepoTrait, }; -pub struct FilterService { - repo: FilterRepo, +pub struct FilterService { + repo: R, } -impl TerminalSize for FilterService {} +impl TerminalSize for FilterService {} -impl FilterService { - pub fn new(repo: FilterRepo) -> Self { +impl FilterService { + pub fn new(repo: R) -> Self { Self { repo } } - pub fn get_filter_uses(&mut self, file_name: &str) -> Result<(), Box> { + pub fn get_filter_uses(&mut self, file_name: &str, out: &mut impl Write) -> Result<(), Box> { let uses = self.repo.find_filter_uses(file_name)?; if uses.is_empty() { - println!("No use for file/module found!"); + writeln!(out, "No use for file/module found!")?; } else { uses.into_iter().for_each(|filter_use: FilterUses| { - let is_active = if filter_use.active == 1 { - "true".to_string() - } else { - "false".to_string() - }; - println!("{}", "-".repeat(self.get_width())); - - println!("Module ID: {}", filter_use.filter_module_id); - println!("ModuleNo: {}", filter_use.filter_module_no); - println!("FilterUser: {}", filter_use.filter_user); - println!("Description: {}", filter_use.description); - println!("EP_NO: {}", filter_use.ep_no); - println!("FilterId: {}", filter_use.filter_id); - println!("FilterNo: {}", filter_use.filter_no); - println!("Merchant: {}", filter_use.merchant); - println!("Active: {}", is_active); + let is_active = if filter_use.active == 1 { "true" } else { "false" }; + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "Module ID: {}", filter_use.filter_module_id).ok(); + writeln!(out, "ModuleNo: {}", filter_use.filter_module_no).ok(); + writeln!(out, "FilterUser: {}", filter_use.filter_user).ok(); + writeln!(out, "Description: {}", filter_use.description).ok(); + writeln!(out, "EP_NO: {}", filter_use.ep_no).ok(); + writeln!(out, "FilterId: {}", filter_use.filter_id).ok(); + writeln!(out, "FilterNo: {}", filter_use.filter_no).ok(); + writeln!(out, "Merchant: {}", filter_use.merchant).ok(); + writeln!(out, "Active: {}", is_active).ok(); }); - } Ok(()) @@ -51,25 +47,26 @@ impl FilterService { &mut self, filter_id: &usize, all: bool, + out: &mut impl Write, ) -> Result<(), Box> { let filters = self.repo.find_by_id(filter_id)?; if filters.is_empty() { - println!("Filter not found!"); + writeln!(out, "Filter not found!")?; } else { filters.into_iter().for_each(|filter: Filter| { - println!("{}", "-".repeat(self.get_width())); - println!("Module ID: {}", filter.filter_module_id); - println!("FileName: {}", filter.file_name); - println!("Description: {}", filter.description); - println!("ModuleNo: {}", filter.filter_module_no); - println!("FilterUser: {}", filter.filter_user); + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "Module ID: {}", filter.filter_module_id).ok(); + writeln!(out, "FileName: {}", filter.file_name).ok(); + writeln!(out, "Description: {}", filter.description).ok(); + writeln!(out, "ModuleNo: {}", filter.filter_module_no).ok(); + writeln!(out, "FilterUser: {}", filter.filter_user).ok(); }); if all { - self.get_filter_configs(filter_id)? + self.get_filter_configs(filter_id, out)?; } else { - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; } }; @@ -79,26 +76,28 @@ impl FilterService { pub fn get_filter_configs( &mut self, filter_id: &usize, + out: &mut impl Write, ) -> Result<(), Box> { let filter_configs = self.repo.find_filter_configs(filter_id)?; if filter_configs.is_empty() { - println!("No filter configs found!"); + writeln!(out, "No filter configs found!")?; } else { - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; filter_configs .into_iter() .for_each(|filter_config: FilterConfig| { - println!( + writeln!( + out, "{0: <25} | {1: <20} | {2: <20} | {3: <10} | {4: <10}", filter_config.attribute, filter_config.value1, filter_config.value2.unwrap_or("n/a".to_string()), filter_config.upd_ts, filter_config.name - ); + ).ok(); }); - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; }; Ok(()) @@ -108,45 +107,329 @@ impl FilterService { &mut self, filter_id: &usize, all: bool, + out: &mut impl Write, ) -> Result<(), Box> { let filter_log = self.repo.find_filter_log(filter_id)?; if filter_log.is_empty() { - println!("No filter log found!"); + writeln!(out, "No filter log found!")?; } else { - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; filter_log.into_iter().for_each(|filter_log: FilterLog| { - println!( - "TS: {} Error Code: {}", - filter_log.run_ts, filter_log.error_code - ); + writeln!(out, "TS: {} Error Code: {}", filter_log.run_ts, filter_log.error_code).ok(); match filter_log.error_code.as_str() { "WARNING" => { - println!("{}", filter_log.error_msg.yellow()); + writeln!(out, "{}", filter_log.error_msg.yellow()).ok(); if all { - println!("{}", filter_log.mysql_error.yellow()); + writeln!(out, "{}", filter_log.mysql_error.yellow()).ok(); } } "ERROR" => { - println!("{}", filter_log.error_msg.red()); + writeln!(out, "{}", filter_log.error_msg.red()).ok(); if all { - println!("{}", filter_log.mysql_error.red()); + writeln!(out, "{}", filter_log.mysql_error.red()).ok(); } } "DEBUG" => { - println!("{}", filter_log.error_msg.green()); + writeln!(out, "{}", filter_log.error_msg.green()).ok(); if all { - println!("{}", filter_log.error_msg.green()) + writeln!(out, "{}", filter_log.error_msg.green()).ok(); } } _ => { - println!("{}", filter_log.error_msg); + writeln!(out, "{}", filter_log.error_msg).ok(); } } - - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); }) } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + entity::{ + filter::Filter, filter_config::FilterConfig, filter_log::FilterLog, + filter_uses::FilterUses, + }, + repository::filter_repo::FilterRepoTrait, + }; + use time::{Date, Month, PrimitiveDateTime, Time}; + + fn test_dt() -> PrimitiveDateTime { + PrimitiveDateTime::new( + Date::from_calendar_date(2024, Month::January, 1).unwrap(), + Time::MIDNIGHT, + ) + } + + struct MockFilterRepo { + filters: Vec, + configs: Vec, + logs: Vec, + uses: Vec, + } + + impl MockFilterRepo { + fn empty() -> Self { + Self { filters: vec![], configs: vec![], logs: vec![], uses: vec![] } + } + } + + impl FilterRepoTrait for MockFilterRepo { + fn find_by_id(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.filters)) + } + fn find_filter_configs(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.configs)) + } + fn find_filter_log(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.logs)) + } + fn find_filter_uses(&mut self, _: &str) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.uses)) + } + } + + fn capture(f: impl FnOnce(&mut FilterService, &mut Vec)) -> String { + colored::control::set_override(false); + let repo = MockFilterRepo::empty(); + let mut svc = FilterService::new(repo); + let mut out = Vec::new(); + f(&mut svc, &mut out); + String::from_utf8(out).unwrap() + } + + fn capture_with( + repo: MockFilterRepo, + f: impl FnOnce(&mut FilterService, &mut Vec), + ) -> String { + colored::control::set_override(false); + let mut svc = FilterService::new(repo); + let mut out = Vec::new(); + f(&mut svc, &mut out); + String::from_utf8(out).unwrap() + } + + // --- get_filter --- + + #[test] + fn get_filter_not_found() { + let out = capture(|svc, out| { svc.get_filter(&1, false, out).unwrap(); }); + assert_eq!(out, "Filter not found!\n"); + } + + #[test] + fn get_filter_prints_fields() { + let repo = MockFilterRepo { + filters: vec![Filter { + filter_module_id: 42, + file_name: "foo.php".into(), + description: "My filter".into(), + filter_module_no: "FM-7".into(), + filter_user: 99, + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter(&1, false, out).unwrap(); }); + assert!(out.contains("Module ID: 42")); + assert!(out.contains("FileName: foo.php")); + assert!(out.contains("Description: My filter")); + assert!(out.contains("ModuleNo: FM-7")); + assert!(out.contains("FilterUser: 99")); + } + + #[test] + fn get_filter_all_calls_configs() { + let repo = MockFilterRepo { + filters: vec![Filter { + filter_module_id: 1, + file_name: "f.php".into(), + description: "d".into(), + filter_module_no: "FM-1".into(), + filter_user: 1, + }], + configs: vec![FilterConfig { + attribute: "attr1".into(), + value1: "val1".into(), + value2: None, + name: "user1".into(), + upd_ts: test_dt(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter(&1, true, out).unwrap(); }); + assert!(out.contains("attr1")); + assert!(out.contains("val1")); + } + + // --- get_filter_configs --- + + #[test] + fn get_filter_configs_empty() { + let out = capture(|svc, out| { svc.get_filter_configs(&1, out).unwrap(); }); + assert_eq!(out, "No filter configs found!\n"); + } + + #[test] + fn get_filter_configs_prints_fields() { + let repo = MockFilterRepo { + configs: vec![FilterConfig { + attribute: "colour".into(), + value1: "blue".into(), + value2: Some("dark".into()), + name: "admin".into(), + upd_ts: test_dt(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_configs(&1, out).unwrap(); }); + assert!(out.contains("colour")); + assert!(out.contains("blue")); + assert!(out.contains("dark")); + assert!(out.contains("admin")); + } + + #[test] + fn get_filter_configs_none_value2_shows_na() { + let repo = MockFilterRepo { + configs: vec![FilterConfig { + attribute: "x".into(), + value1: "y".into(), + value2: None, + name: "n".into(), + upd_ts: test_dt(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_configs(&1, out).unwrap(); }); + assert!(out.contains("n/a")); + } + + // --- get_filter_log --- + + #[test] + fn get_filter_log_empty() { + let out = capture(|svc, out| { svc.get_filter_log(&1, false, out).unwrap(); }); + assert_eq!(out, "No filter log found!\n"); + } + + #[test] + fn get_filter_log_shows_error_code_and_msg() { + let repo = MockFilterRepo { + logs: vec![FilterLog { + run_ts: test_dt(), + error_code: "ERROR".into(), + error_msg: "something broke".into(), + mysql_error: "sql err".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_log(&1, false, out).unwrap(); }); + assert!(out.contains("Error Code: ERROR")); + assert!(out.contains("something broke")); + assert!(!out.contains("sql err")); + } + + #[test] + fn get_filter_log_all_shows_mysql_error() { + let repo = MockFilterRepo { + logs: vec![FilterLog { + run_ts: test_dt(), + error_code: "WARNING".into(), + error_msg: "warn msg".into(), + mysql_error: "mysql detail".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_log(&1, true, out).unwrap(); }); + assert!(out.contains("warn msg")); + assert!(out.contains("mysql detail")); + } + + #[test] + fn get_filter_log_debug_code() { + let repo = MockFilterRepo { + logs: vec![FilterLog { + run_ts: test_dt(), + error_code: "DEBUG".into(), + error_msg: "debug info".into(), + mysql_error: "".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_log(&1, false, out).unwrap(); }); + assert!(out.contains("debug info")); + } + + #[test] + fn get_filter_log_unknown_code_printed_plain() { + let repo = MockFilterRepo { + logs: vec![FilterLog { + run_ts: test_dt(), + error_code: "INFO".into(), + error_msg: "plain message".into(), + mysql_error: "".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_log(&1, false, out).unwrap(); }); + assert!(out.contains("plain message")); + } + + // --- get_filter_uses --- + + #[test] + fn get_filter_uses_empty() { + let out = capture(|svc, out| { svc.get_filter_uses("foo.php", out).unwrap(); }); + assert_eq!(out, "No use for file/module found!\n"); + } + + #[test] + fn get_filter_uses_prints_fields() { + let repo = MockFilterRepo { + uses: vec![FilterUses { + filter_module_id: 7, + description: "desc".into(), + filter_module_no: "FM-2".into(), + filter_user: 3, + filter_id: 10, + active: 1, + merchant: "Shop A".into(), + ep_id: 5, + ep_no: "EP-9".into(), + filter_no: "F-11".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_uses("foo.php", out).unwrap(); }); + assert!(out.contains("Module ID: 7")); + assert!(out.contains("Merchant: Shop A")); + assert!(out.contains("Active: true")); + assert!(out.contains("EP_NO: EP-9")); + } + + #[test] + fn get_filter_uses_inactive_shows_false() { + let repo = MockFilterRepo { + uses: vec![FilterUses { + filter_module_id: 1, + description: "d".into(), + filter_module_no: "FM".into(), + filter_user: 1, + filter_id: 1, + active: 0, + merchant: "M".into(), + ep_id: 1, + ep_no: "E".into(), + filter_no: "F".into(), + }], + ..MockFilterRepo::empty() + }; + let out = capture_with(repo, |svc, out| { svc.get_filter_uses("x", out).unwrap(); }); + assert!(out.contains("Active: false")); + } +} diff --git a/src/service/job_service.rs b/src/service/job_service.rs index f3e5973..a6dc3ab 100644 --- a/src/service/job_service.rs +++ b/src/service/job_service.rs @@ -1,52 +1,135 @@ +use std::io::Write; + use rcc::TerminalSize; use crate::{ entity::job::{Job, JobLog}, - repository::job_repo::JobRepo, + repository::job_repo::JobRepoTrait, }; -pub struct JobService { - pub repo: JobRepo, +pub struct JobService { + pub repo: R, } -impl TerminalSize for JobService {} +impl TerminalSize for JobService {} -impl JobService { - pub fn new(repo: JobRepo) -> Self { +impl JobService { + pub fn new(repo: R) -> Self { Self { repo } } - pub fn get_job_by_id(&mut self, job_id: &usize) -> Result<(), Box> { + pub fn get_job_by_id(&mut self, job_id: &usize, out: &mut impl Write) -> Result<(), Box> { let result = self.repo.find_by_id(job_id)?; if result.is_empty() { - println!("Job not found!"); + writeln!(out, "Job not found!")?; } else { result.into_iter().for_each(|job: Job| { - println!("{}", "-".repeat(self.get_width())); - println!("FileName: {}", job.file_name); - println!("Description: {}", job.description); + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "FileName: {}", job.file_name).ok(); + writeln!(out, "Description: {}", job.description).ok(); }); - - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; } Ok(()) } - pub fn get_job_log(&mut self, job_id: &usize) -> Result<(), Box> { + pub fn get_job_log(&mut self, job_id: &usize, out: &mut impl Write) -> Result<(), Box> { let result = self.repo.find_log(job_id)?; if result.is_empty() { - println!("Job log not found!"); + writeln!(out, "Job log not found!")?; } else { result.into_iter().for_each(|joblog: JobLog| { - println!("{0: <25} | {1: <20} ", joblog.upd_ts, joblog.content,); + writeln!(out, "{0: <25} | {1: <20} ", joblog.upd_ts, joblog.content).ok(); }); - - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + entity::job::{Job, JobLog}, + repository::job_repo::JobRepoTrait, + }; + use time::{Date, Month, PrimitiveDateTime, Time}; + + fn test_dt() -> PrimitiveDateTime { + PrimitiveDateTime::new( + Date::from_calendar_date(2024, Month::June, 15).unwrap(), + Time::MIDNIGHT, + ) + } + + struct MockJobRepo { + jobs: Vec, + logs: Vec, + } + + impl MockJobRepo { + fn empty() -> Self { + Self { jobs: vec![], logs: vec![] } + } + } + + impl JobRepoTrait for MockJobRepo { + fn find_by_id(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.jobs)) + } + fn find_log(&mut self, _: &usize) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.logs)) + } + } + + fn run_job(repo: MockJobRepo) -> String { + let mut svc = JobService::new(repo); + let mut out = Vec::new(); + svc.get_job_by_id(&1, &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + fn run_log(repo: MockJobRepo) -> String { + let mut svc = JobService::new(repo); + let mut out = Vec::new(); + svc.get_job_log(&1, &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn job_not_found() { + let out = run_job(MockJobRepo::empty()); + assert_eq!(out, "Job not found!\n"); + } + + #[test] + fn job_prints_fields() { + let out = run_job(MockJobRepo { + jobs: vec![Job { file_name: "import.php".into(), description: "Imports stuff".into() }], + ..MockJobRepo::empty() + }); + assert!(out.contains("FileName: import.php")); + assert!(out.contains("Description: Imports stuff")); + } + + #[test] + fn job_log_not_found() { + let out = run_log(MockJobRepo::empty()); + assert_eq!(out, "Job log not found!\n"); + } + + #[test] + fn job_log_prints_content_and_timestamp() { + let out = run_log(MockJobRepo { + logs: vec![JobLog { content: "log line".into(), upd_ts: test_dt() }], + ..MockJobRepo::empty() + }); + assert!(out.contains("log line")); + assert!(out.contains("2024-06-15")); + } +} diff --git a/src/service/merchant_service.rs b/src/service/merchant_service.rs index 1fb9644..1a47d52 100644 --- a/src/service/merchant_service.rs +++ b/src/service/merchant_service.rs @@ -1,32 +1,80 @@ +use std::io::Write; + use rcc::TerminalSize; -use crate::repository::merchant_repo::MerchantRepo; +use crate::{entity::merchant::Merchant, repository::merchant_repo::MerchantRepoTrait}; -pub struct MerchantService { - repo: MerchantRepo, +pub struct MerchantService { + repo: R, } -impl TerminalSize for MerchantService {} +impl TerminalSize for MerchantService {} -impl MerchantService { - pub fn new(repo: MerchantRepo) -> Self { +impl MerchantService { + pub fn new(repo: R) -> Self { Self { repo } } - pub fn get_merchant(&mut self, search: &str) -> Result<(), Box> { + pub fn get_merchant(&mut self, search: &str, out: &mut impl Write) -> Result<(), Box> { let merchants = self.repo.find_by_name_or_id(search)?; if merchants.is_empty() { - println!("Merchant not found!"); + writeln!(out, "Merchant not found!")?; } - merchants.into_iter().for_each(|merchant| { - println!("{}", "-".repeat(self.get_width())); - println!("Merchant: {}", merchant.m_name); - println!("M-ID: {}", merchant.m_id); + merchants.into_iter().for_each(|merchant: Merchant| { + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "Merchant: {}", merchant.m_name).ok(); + writeln!(out, "M-ID: {}", merchant.m_id).ok(); }); - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{entity::merchant::Merchant, repository::merchant_repo::MerchantRepoTrait}; + + struct MockMerchantRepo { + merchants: Vec, + } + + impl MerchantRepoTrait for MockMerchantRepo { + fn find_by_name_or_id(&mut self, _: &str) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.merchants)) + } + } + + fn run(repo: MockMerchantRepo) -> String { + let mut svc = MerchantService::new(repo); + let mut out = Vec::new(); + svc.get_merchant("any", &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn not_found_message() { + let out = run(MockMerchantRepo { merchants: vec![] }); + assert!(out.contains("Merchant not found!")); + } + + #[test] + fn prints_merchant_fields() { + let out = run(MockMerchantRepo { + merchants: vec![Merchant { m_id: 5, m_name: "Acme".into() }], + }); + assert!(out.contains("Merchant: Acme")); + assert!(out.contains("M-ID: 5")); + } + + #[test] + fn prints_separator_after_results() { + let out = run(MockMerchantRepo { + merchants: vec![Merchant { m_id: 1, m_name: "X".into() }], + }); + assert!(out.contains('-')); + } +} diff --git a/src/service/schema_service.rs b/src/service/schema_service.rs index b650e6f..de69f7e 100644 --- a/src/service/schema_service.rs +++ b/src/service/schema_service.rs @@ -1,32 +1,90 @@ +use std::io::Write; + use rcc::TerminalSize; -use crate::repository::schema_repo::SchemaRepo; +use crate::{entity::schema::Schema, repository::schema_repo::SchemaRepoTrait}; -pub struct SchemaService { - repo: SchemaRepo, +pub struct SchemaService { + repo: R, } -impl TerminalSize for SchemaService {} +impl TerminalSize for SchemaService {} -impl SchemaService { - pub fn new(repo: SchemaRepo) -> Self { +impl SchemaService { + pub fn new(repo: R) -> Self { Self { repo } } - pub fn get_columns(&mut self, column: &str) -> Result<(), Box> { + + pub fn get_columns(&mut self, column: &str, out: &mut impl Write) -> Result<(), Box> { let columns = self.repo.find_by_column(column)?; if columns.is_empty() { - println!("No column found!"); + writeln!(out, "No column found!")?; } else { - columns.into_iter().for_each(|column| { - println!("{}", "-".repeat(self.get_width())); - println!("Table name: {}", column.table_name); - println!("Column name: {}", column.column_name); - println!("Type: {}", column.column_type); + columns.into_iter().for_each(|column: Schema| { + writeln!(out, "{}", "-".repeat(self.get_width())).ok(); + writeln!(out, "Table name: {}", column.table_name).ok(); + writeln!(out, "Column name: {}", column.column_name).ok(); + writeln!(out, "Type: {}", column.column_type).ok(); }); - println!("{}", "-".repeat(self.get_width())); + writeln!(out, "{}", "-".repeat(self.get_width()))?; } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{entity::schema::Schema, repository::schema_repo::SchemaRepoTrait}; + + struct MockSchemaRepo { + columns: Vec, + } + + impl SchemaRepoTrait for MockSchemaRepo { + fn find_by_column(&mut self, _: &str) -> Result, mysql::Error> { + Ok(std::mem::take(&mut self.columns)) + } + } + + fn run(repo: MockSchemaRepo) -> String { + let mut svc = SchemaService::new(repo); + let mut out = Vec::new(); + svc.get_columns("any", &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn not_found_message() { + let out = run(MockSchemaRepo { columns: vec![] }); + assert_eq!(out, "No column found!\n"); + } + + #[test] + fn prints_schema_fields() { + let out = run(MockSchemaRepo { + columns: vec![Schema { + table_name: "orders".into(), + column_name: "status".into(), + column_type: "varchar(32)".into(), + }], + }); + assert!(out.contains("Table name: orders")); + assert!(out.contains("Column name: status")); + assert!(out.contains("Type: varchar(32)")); + } + + #[test] + fn multiple_columns_all_printed() { + let out = run(MockSchemaRepo { + columns: vec![ + Schema { table_name: "t1".into(), column_name: "c1".into(), column_type: "int".into() }, + Schema { table_name: "t2".into(), column_name: "c2".into(), column_type: "text".into() }, + ], + }); + assert!(out.contains("t1")); + assert!(out.contains("t2")); + } +}