Skip to main content

gilt_derive/
lib.rs

1//! Derive macros for the gilt terminal formatting library.
2//!
3//! This crate provides the `#[derive(Table)]`, `#[derive(Panel)]`, `#[derive(Tree)]`,
4//! `#[derive(Columns)]`, `#[derive(Rule)]`, `#[derive(Inspect)]`, and `#[derive(Renderable)]` macros that generate widget
5//! conversion methods and trait implementations for structs.
6//!
7//! # Table Example
8//!
9//! ```ignore
10//! use gilt::Table;
11//!
12//! #[derive(Table)]
13//! #[table(title = "Employees", box_style = "ROUNDED", header_style = "bold cyan")]
14//! struct Employee {
15//!     #[column(header = "Full Name", style = "bold")]
16//!     name: String,
17//!     #[column(justify = "right")]
18//!     age: u32,
19//!     #[column(skip)]
20//!     internal_id: u64,
21//!     #[column(header = "Dept", style = "green", min_width = 10)]
22//!     department: String,
23//! }
24//!
25//! let employees = vec![
26//!     Employee {
27//!         name: "Alice".into(),
28//!         age: 30,
29//!         internal_id: 1001,
30//!         department: "Engineering".into(),
31//!     },
32//!     Employee {
33//!         name: "Bob".into(),
34//!         age: 25,
35//!         internal_id: 1002,
36//!         department: "Marketing".into(),
37//!     },
38//! ];
39//! let table = Employee::to_table(&employees);
40//! ```
41//!
42//! # Panel Example
43//!
44//! ```ignore
45//! use gilt::Panel;
46//!
47//! #[derive(Panel)]
48//! #[panel(title = "Server Status", box_style = "ROUNDED", border_style = "blue")]
49//! struct ServerStatus {
50//!     #[field(label = "Host", style = "bold cyan")]
51//!     name: String,
52//!     #[field(label = "CPU %", style = "yellow")]
53//!     cpu: f32,
54//!     #[field(skip)]
55//!     internal_id: u64,
56//!     #[field(label = "Memory", style = "green")]
57//!     memory: f32,
58//! }
59//!
60//! let status = ServerStatus {
61//!     name: "web-01".into(),
62//!     cpu: 42.5,
63//!     internal_id: 1001,
64//!     memory: 67.3,
65//! };
66//! let panel = status.to_panel();
67//! ```
68
69mod columns;
70mod inspect;
71mod panel;
72mod renderable;
73mod rule;
74mod shared;
75mod table;
76mod tree;
77
78use proc_macro::TokenStream;
79#[cfg(test)]
80use proc_macro2::Span;
81use syn::{parse_macro_input, DeriveInput};
82
83// ===========================================================================
84// Tree derive entry point (impl moved to crate::tree in v0.11.4)
85// ===========================================================================
86
87/// Derive macro generating `to_tree(&self) -> gilt::tree::Tree`.
88/// See [`crate::tree`] for full attribute schema.
89#[proc_macro_derive(Tree, attributes(tree))]
90pub fn derive_tree(input: TokenStream) -> TokenStream {
91    let input = parse_macro_input!(input as DeriveInput);
92    match tree::derive_tree_impl(&input) {
93        Ok(ts) => ts.into(),
94        Err(e) => e.to_compile_error().into(),
95    }
96}
97
98// ===========================================================================
99// Renderable derive entry point (impl moved to crates::renderable in v0.11.4)
100// ===========================================================================
101
102/// Derive macro that generates a `gilt::console::Renderable` implementation
103/// for a struct, delegating to one of the existing widget derives.
104///
105/// See [`crate::renderable`] for full attribute schema and behaviour.
106#[proc_macro_derive(Renderable, attributes(renderable))]
107pub fn derive_renderable(input: TokenStream) -> TokenStream {
108    let input = parse_macro_input!(input as DeriveInput);
109    match renderable::derive_renderable_impl(&input) {
110        Ok(ts) => ts.into(),
111        Err(e) => e.to_compile_error().into(),
112    }
113}
114
115// ===========================================================================
116// Inspect derive entry point (impl moved to crate::inspect in v0.11.4)
117// ===========================================================================
118
119/// Derive macro that generates a `to_inspect(&self) -> gilt::inspect::Inspect`
120/// method for structs implementing `Debug`. See [`crate::inspect`] for the
121/// full attribute schema.
122#[proc_macro_derive(Inspect, attributes(inspect))]
123pub fn derive_inspect(input: TokenStream) -> TokenStream {
124    let input = parse_macro_input!(input as DeriveInput);
125    match inspect::derive_inspect_impl(&input) {
126        Ok(ts) => ts.into(),
127        Err(e) => e.to_compile_error().into(),
128    }
129}
130
131// ===========================================================================
132// Rule derive entry point (impl moved to crate::rule in v0.11.4)
133// ===========================================================================
134
135/// Derive macro that generates a `to_rule(&self) -> gilt::rule::Rule` method.
136/// See [`crate::rule`] for the full attribute schema.
137#[proc_macro_derive(Rule, attributes(rule))]
138pub fn derive_rule(input: TokenStream) -> TokenStream {
139    let input = parse_macro_input!(input as DeriveInput);
140    match rule::derive_rule_impl(&input) {
141        Ok(ts) => ts.into(),
142        Err(e) => e.to_compile_error().into(),
143    }
144}
145
146// ===========================================================================
147// Columns derive entry point (impl moved to crate::columns in v0.11.4)
148// ===========================================================================
149
150/// Derive macro generating `to_columns(items: &[Self]) -> gilt::columns::Columns`.
151/// See [`crate::columns`] for full attribute schema.
152#[proc_macro_derive(Columns, attributes(columns, field))]
153pub fn derive_columns(input: TokenStream) -> TokenStream {
154    let input = parse_macro_input!(input as DeriveInput);
155    match columns::derive_columns_impl(&input) {
156        Ok(ts) => ts.into(),
157        Err(e) => e.to_compile_error().into(),
158    }
159}
160
161// ===========================================================================
162// Panel derive entry point (impl moved to crate::panel in v0.11.4)
163// ===========================================================================
164
165/// Derive macro generating `to_panel(&self) -> gilt::panel::Panel`.
166/// See [`crate::panel`] for full attribute schema.
167#[proc_macro_derive(Panel, attributes(panel, field))]
168pub fn derive_panel(input: TokenStream) -> TokenStream {
169    let input = parse_macro_input!(input as DeriveInput);
170    match panel::derive_panel_impl(&input) {
171        Ok(ts) => ts.into(),
172        Err(e) => e.to_compile_error().into(),
173    }
174}
175
176// ===========================================================================
177// Table derive entry point (impl moved to crate::table in v0.11.4)
178// ===========================================================================
179
180/// Derive macro generating `to_table(items: &[Self]) -> gilt::table::Table`.
181/// See [`crate::table`] for full attribute schema.
182#[proc_macro_derive(Table, attributes(table, column))]
183pub fn derive_table(input: TokenStream) -> TokenStream {
184    let input = parse_macro_input!(input as DeriveInput);
185    match table::derive_table_impl(&input) {
186        Ok(ts) => ts.into(),
187        Err(e) => e.to_compile_error().into(),
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Tests
193// ---------------------------------------------------------------------------
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::shared::{box_style_tokens, justify_tokens, snake_to_title_case};
199    use syn::LitStr;
200
201    // -- snake_to_title_case -----------------------------------------------
202
203    #[test]
204    fn test_snake_to_title_case() {
205        assert_eq!(snake_to_title_case("first_name"), "First Name");
206        assert_eq!(snake_to_title_case("age"), "Age");
207        assert_eq!(snake_to_title_case("department_id"), "Department Id");
208        assert_eq!(snake_to_title_case("a_b_c"), "A B C");
209        assert_eq!(snake_to_title_case("single"), "Single");
210    }
211
212    #[test]
213    fn test_snake_to_title_case_edge_cases() {
214        assert_eq!(snake_to_title_case(""), "");
215        assert_eq!(snake_to_title_case("_leading"), "Leading");
216        assert_eq!(snake_to_title_case("trailing_"), "Trailing");
217        assert_eq!(snake_to_title_case("__double__"), "Double");
218        assert_eq!(snake_to_title_case("ALL_CAPS"), "ALL CAPS");
219    }
220
221    // -- box_style_tokens --------------------------------------------------
222
223    #[test]
224    fn test_box_style_tokens_valid() {
225        let valid = [
226            "ASCII",
227            "ASCII2",
228            "ASCII_DOUBLE_HEAD",
229            "SQUARE",
230            "SQUARE_DOUBLE_HEAD",
231            "MINIMAL",
232            "MINIMAL_HEAVY_HEAD",
233            "MINIMAL_DOUBLE_HEAD",
234            "SIMPLE",
235            "SIMPLE_HEAD",
236            "SIMPLE_HEAVY",
237            "HORIZONTALS",
238            "ROUNDED",
239            "HEAVY",
240            "HEAVY_EDGE",
241            "HEAVY_HEAD",
242            "DOUBLE",
243            "DOUBLE_EDGE",
244            "MARKDOWN",
245        ];
246        for name in valid {
247            let lit = LitStr::new(name, Span::call_site());
248            assert!(
249                box_style_tokens(&lit).is_ok(),
250                "box_style_tokens should accept `{}`",
251                name
252            );
253        }
254    }
255
256    #[test]
257    fn test_box_style_tokens_invalid() {
258        let lit = LitStr::new("NONEXISTENT", Span::call_site());
259        let result = box_style_tokens(&lit);
260        assert!(result.is_err());
261        let err_msg = result.unwrap_err().to_string();
262        assert!(
263            err_msg.contains("unknown box_style"),
264            "error should mention unknown box_style, got: {}",
265            err_msg
266        );
267    }
268
269    // -- justify_tokens ----------------------------------------------------
270
271    #[test]
272    fn test_justify_tokens_valid() {
273        for name in ["left", "center", "right", "full"] {
274            let lit = LitStr::new(name, Span::call_site());
275            assert!(
276                justify_tokens(&lit).is_ok(),
277                "justify_tokens should accept `{}`",
278                name
279            );
280        }
281    }
282
283    #[test]
284    fn test_justify_tokens_invalid() {
285        let lit = LitStr::new("middle", Span::call_site());
286        let result = justify_tokens(&lit);
287        assert!(result.is_err());
288        let err_msg = result.unwrap_err().to_string();
289        assert!(
290            err_msg.contains("unknown justify"),
291            "error should mention unknown justify, got: {}",
292            err_msg
293        );
294    }
295
296    // -- table::TableAttr parsing -------------------------------------------------
297
298    #[test]
299    fn test_parse_table_attr_str() {
300        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "My Title" };
301        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
302        assert_eq!(attr.key, "title");
303        match attr.value {
304            table::TableAttrValue::Str(s) => assert_eq!(s.value(), "My Title"),
305            _ => panic!("expected Str"),
306        }
307    }
308
309    #[test]
310    fn test_parse_table_attr_bool() {
311        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand = true };
312        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
313        assert_eq!(attr.key, "expand");
314        match attr.value {
315            table::TableAttrValue::Bool(b) => assert!(b.value),
316            _ => panic!("expected Bool"),
317        }
318    }
319
320    #[test]
321    fn test_parse_table_attr_flag() {
322        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand };
323        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
324        assert_eq!(attr.key, "expand");
325        matches!(attr.value, table::TableAttrValue::Flag);
326    }
327
328    // -- table::ColumnAttr parsing ------------------------------------------------
329
330    #[test]
331    fn test_parse_column_attr_str() {
332        let tokens: proc_macro2::TokenStream = syn::parse_quote! { header = "Name" };
333        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
334        assert_eq!(attr.key, "header");
335        match attr.value {
336            table::ColumnAttrValue::Str(s) => assert_eq!(s.value(), "Name"),
337            _ => panic!("expected Str"),
338        }
339    }
340
341    #[test]
342    fn test_parse_column_attr_int() {
343        let tokens: proc_macro2::TokenStream = syn::parse_quote! { width = 42 };
344        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
345        assert_eq!(attr.key, "width");
346        match attr.value {
347            table::ColumnAttrValue::Int(i) => assert_eq!(i.base10_parse::<usize>().unwrap(), 42),
348            _ => panic!("expected Int"),
349        }
350    }
351
352    #[test]
353    fn test_parse_column_attr_bool() {
354        let tokens: proc_macro2::TokenStream = syn::parse_quote! { no_wrap = true };
355        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
356        assert_eq!(attr.key, "no_wrap");
357        match attr.value {
358            table::ColumnAttrValue::Bool(b) => assert!(b.value),
359            _ => panic!("expected Bool"),
360        }
361    }
362
363    #[test]
364    fn test_parse_column_attr_flag() {
365        let tokens: proc_macro2::TokenStream = syn::parse_quote! { skip };
366        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
367        assert_eq!(attr.key, "skip");
368        matches!(attr.value, table::ColumnAttrValue::Flag);
369    }
370
371    // -- expect helpers ----------------------------------------------------
372
373    #[test]
374    fn test_expect_str_ok() {
375        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "hello" };
376        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
377        let result = table::expect_str(&attr, "title");
378        assert!(result.is_ok());
379        assert_eq!(result.unwrap().value(), "hello");
380    }
381
382    #[test]
383    fn test_expect_str_wrong_type() {
384        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = true };
385        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
386        let result = table::expect_str(&attr, "title");
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_expect_bool_ok() {
392        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand = false };
393        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
394        let result = table::expect_bool(&attr, "expand");
395        assert!(result.is_ok());
396        assert!(!result.unwrap().value);
397    }
398
399    #[test]
400    fn test_expect_bool_flag() {
401        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand };
402        let attr: table::TableAttr = syn::parse2(tokens).unwrap();
403        let result = table::expect_bool(&attr, "expand");
404        assert!(result.is_ok());
405        assert!(result.unwrap().value);
406    }
407
408    #[test]
409    fn test_col_expect_int_ok() {
410        let tokens: proc_macro2::TokenStream = syn::parse_quote! { width = 10 };
411        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
412        let result = table::col_expect_int(&attr, "width");
413        assert!(result.is_ok());
414        assert_eq!(result.unwrap().base10_parse::<usize>().unwrap(), 10);
415    }
416
417    #[test]
418    fn test_col_expect_int_wrong_type() {
419        let tokens: proc_macro2::TokenStream = syn::parse_quote! { width = "ten" };
420        let attr: table::ColumnAttr = syn::parse2(tokens).unwrap();
421        let result = table::col_expect_int(&attr, "width");
422        assert!(result.is_err());
423    }
424
425    // -- Full derive round-trip (syn parse) --------------------------------
426
427    #[test]
428    fn test_derive_basic_struct() {
429        let input: DeriveInput = syn::parse_quote! {
430            struct Employee {
431                name: String,
432                age: u32,
433            }
434        };
435        let result = table::derive_table_impl(&input);
436        assert!(
437            result.is_ok(),
438            "derive_table_impl failed: {:?}",
439            result.err()
440        );
441        let tokens = result.unwrap().to_string();
442        assert!(tokens.contains("to_table"));
443        assert!(tokens.contains("\"Name\""));
444        assert!(tokens.contains("\"Age\""));
445    }
446
447    #[test]
448    fn test_derive_with_skip() {
449        let input: DeriveInput = syn::parse_quote! {
450            struct Data {
451                visible: String,
452                #[column(skip)]
453                hidden: u64,
454                also_visible: i32,
455            }
456        };
457        let result = table::derive_table_impl(&input);
458        assert!(result.is_ok());
459        let tokens = result.unwrap().to_string();
460        assert!(tokens.contains("\"Visible\""));
461        assert!(tokens.contains("\"Also Visible\""));
462        // The hidden field should not appear as a header.
463        assert!(!tokens.contains("\"Hidden\""));
464    }
465
466    #[test]
467    fn test_derive_with_custom_header() {
468        let input: DeriveInput = syn::parse_quote! {
469            struct Rec {
470                #[column(header = "Full Name")]
471                name: String,
472            }
473        };
474        let result = table::derive_table_impl(&input);
475        assert!(result.is_ok());
476        let tokens = result.unwrap().to_string();
477        assert!(tokens.contains("\"Full Name\""));
478    }
479
480    #[test]
481    fn test_derive_with_table_attrs() {
482        let input: DeriveInput = syn::parse_quote! {
483            #[table(title = "My Table", box_style = "ROUNDED", show_lines = true)]
484            struct Rec {
485                a: String,
486            }
487        };
488        let result = table::derive_table_impl(&input);
489        assert!(result.is_ok());
490        let tokens = result.unwrap().to_string();
491        assert!(tokens.contains("\"My Table\""));
492        assert!(tokens.contains("ROUNDED"));
493        assert!(tokens.contains("show_lines"));
494    }
495
496    #[test]
497    fn test_derive_with_column_justify() {
498        let input: DeriveInput = syn::parse_quote! {
499            struct Rec {
500                #[column(justify = "right")]
501                amount: f64,
502            }
503        };
504        let result = table::derive_table_impl(&input);
505        assert!(result.is_ok());
506        let tokens = result.unwrap().to_string();
507        assert!(tokens.contains("JustifyMethod"));
508        assert!(tokens.contains("Right"));
509    }
510
511    #[test]
512    fn test_derive_rejects_enum() {
513        let input: DeriveInput = syn::parse_quote! {
514            enum Foo { A, B }
515        };
516        let result = table::derive_table_impl(&input);
517        assert!(result.is_err());
518        assert!(result
519            .unwrap_err()
520            .to_string()
521            .contains("does not support enums"));
522    }
523
524    #[test]
525    fn test_derive_rejects_unknown_table_attr() {
526        let input: DeriveInput = syn::parse_quote! {
527            #[table(nonexistent = "value")]
528            struct Rec {
529                a: String,
530            }
531        };
532        let result = table::derive_table_impl(&input);
533        assert!(result.is_err());
534        assert!(result
535            .unwrap_err()
536            .to_string()
537            .contains("unknown table attribute"),);
538    }
539
540    #[test]
541    fn test_derive_rejects_unknown_column_attr() {
542        let input: DeriveInput = syn::parse_quote! {
543            struct Rec {
544                #[column(nonexistent = "value")]
545                a: String,
546            }
547        };
548        let result = table::derive_table_impl(&input);
549        assert!(result.is_err());
550        assert!(result
551            .unwrap_err()
552            .to_string()
553            .contains("unknown column attribute"),);
554    }
555
556    #[test]
557    fn test_derive_rejects_invalid_justify() {
558        let input: DeriveInput = syn::parse_quote! {
559            struct Rec {
560                #[column(justify = "middle")]
561                a: String,
562            }
563        };
564        let result = table::derive_table_impl(&input);
565        assert!(result.is_err());
566        assert!(result.unwrap_err().to_string().contains("unknown justify"));
567    }
568
569    #[test]
570    fn test_derive_rejects_invalid_box_style() {
571        let input: DeriveInput = syn::parse_quote! {
572            #[table(box_style = "FANCY")]
573            struct Rec {
574                a: String,
575            }
576        };
577        let result = table::derive_table_impl(&input);
578        assert!(result.is_err());
579        assert!(result
580            .unwrap_err()
581            .to_string()
582            .contains("unknown box_style"));
583    }
584
585    #[test]
586    fn test_derive_row_styles() {
587        let input: DeriveInput = syn::parse_quote! {
588            #[table(row_styles = "bold, dim")]
589            struct Rec {
590                a: String,
591            }
592        };
593        let result = table::derive_table_impl(&input);
594        assert!(result.is_ok());
595        let tokens = result.unwrap().to_string();
596        assert!(tokens.contains("row_styles"));
597        assert!(tokens.contains("\"bold\""));
598        assert!(tokens.contains("\"dim\""));
599    }
600
601    #[test]
602    fn test_derive_column_width_attrs() {
603        let input: DeriveInput = syn::parse_quote! {
604            struct Rec {
605                #[column(width = 20, min_width = 5, max_width = 50, ratio = 2)]
606                a: String,
607            }
608        };
609        let result = table::derive_table_impl(&input);
610        assert!(result.is_ok());
611        let tokens = result.unwrap().to_string();
612        assert!(tokens.contains("width"));
613        assert!(tokens.contains("min_width"));
614        assert!(tokens.contains("max_width"));
615        assert!(tokens.contains("ratio"));
616    }
617
618    #[test]
619    fn test_derive_expand_flag() {
620        let input: DeriveInput = syn::parse_quote! {
621            #[table(expand)]
622            struct Rec {
623                a: String,
624            }
625        };
626        let result = table::derive_table_impl(&input);
627        assert!(result.is_ok());
628        let tokens = result.unwrap().to_string();
629        assert!(tokens.contains("set_expand"));
630    }
631
632    // -- Panel derive tests ------------------------------------------------
633
634    #[test]
635    fn test_derive_panel_basic() {
636        let input: DeriveInput = syn::parse_quote! {
637            struct Server {
638                name: String,
639                cpu: f32,
640            }
641        };
642        let result = panel::derive_panel_impl(&input);
643        assert!(
644            result.is_ok(),
645            "derive_panel_impl failed: {:?}",
646            result.err()
647        );
648        let tokens = result.unwrap().to_string();
649        assert!(
650            tokens.contains("to_panel"),
651            "should generate to_panel method"
652        );
653        assert!(
654            tokens.contains("\"Name\""),
655            "should contain default label 'Name'"
656        );
657        assert!(
658            tokens.contains("\"Cpu\""),
659            "should contain default label 'Cpu'"
660        );
661        assert!(tokens.contains("Panel"), "should reference Panel type");
662        assert!(
663            tokens.contains("from_markup"),
664            "should use from_markup for content"
665        );
666        // Default title should be the struct name.
667        assert!(
668            tokens.contains("\"Server\""),
669            "default title should be struct name"
670        );
671    }
672
673    #[test]
674    fn test_derive_panel_with_attrs() {
675        let input: DeriveInput = syn::parse_quote! {
676            #[panel(
677                title = "Server Status",
678                subtitle = "Last updated",
679                box_style = "HEAVY",
680                border_style = "blue",
681                style = "white",
682                expand = false,
683                highlight = true
684            )]
685            struct Server {
686                #[field(label = "Host", style = "bold cyan")]
687                name: String,
688                cpu: f32,
689            }
690        };
691        let result = panel::derive_panel_impl(&input);
692        assert!(
693            result.is_ok(),
694            "derive_panel_impl failed: {:?}",
695            result.err()
696        );
697        let tokens = result.unwrap().to_string();
698        assert!(
699            tokens.contains("\"Server Status\""),
700            "should contain custom title"
701        );
702        assert!(
703            tokens.contains("\"Last updated\""),
704            "should contain subtitle"
705        );
706        assert!(tokens.contains("HEAVY"), "should reference HEAVY box style");
707        assert!(tokens.contains("\"blue\""), "should contain border_style");
708        assert!(tokens.contains("\"white\""), "should contain style");
709        assert!(tokens.contains("expand"), "should set expand");
710        assert!(tokens.contains("highlight"), "should set highlight");
711        assert!(tokens.contains("\"Host\""), "should use custom label");
712        assert!(
713            tokens.contains("bold cyan"),
714            "should contain field style markup"
715        );
716    }
717
718    #[test]
719    fn test_derive_panel_skip_field() {
720        let input: DeriveInput = syn::parse_quote! {
721            struct Data {
722                visible: String,
723                #[field(skip)]
724                hidden: u64,
725                also_visible: i32,
726            }
727        };
728        let result = panel::derive_panel_impl(&input);
729        assert!(result.is_ok());
730        let tokens = result.unwrap().to_string();
731        assert!(
732            tokens.contains("\"Visible\""),
733            "should include visible field"
734        );
735        assert!(
736            tokens.contains("\"Also Visible\""),
737            "should include also_visible field"
738        );
739        // The hidden field should not appear.
740        assert!(!tokens.contains("\"Hidden\""), "should skip hidden field");
741        // Ensure the hidden field ident is not referenced.
742        assert!(
743            !tokens.contains("hidden"),
744            "hidden field ident should not appear"
745        );
746    }
747
748    #[test]
749    fn test_derive_panel_custom_labels() {
750        let input: DeriveInput = syn::parse_quote! {
751            struct Status {
752                #[field(label = "Host Name")]
753                server_name: String,
754                #[field(label = "CPU %", style = "yellow")]
755                cpu_usage: f32,
756                #[field(label = "Mem (GB)")]
757                memory_gb: f64,
758            }
759        };
760        let result = panel::derive_panel_impl(&input);
761        assert!(result.is_ok());
762        let tokens = result.unwrap().to_string();
763        assert!(
764            tokens.contains("\"Host Name\""),
765            "should use custom label 'Host Name'"
766        );
767        assert!(
768            tokens.contains("\"CPU %\""),
769            "should use custom label 'CPU %'"
770        );
771        assert!(
772            tokens.contains("\"Mem (GB)\""),
773            "should use custom label 'Mem (GB)'"
774        );
775        // Default Title Case labels should NOT appear.
776        assert!(
777            !tokens.contains("\"Server Name\""),
778            "should not use default label"
779        );
780        assert!(
781            !tokens.contains("\"Cpu Usage\""),
782            "should not use default label"
783        );
784        assert!(
785            !tokens.contains("\"Memory Gb\""),
786            "should not use default label"
787        );
788    }
789
790    #[test]
791    fn test_derive_panel_rejects_enum() {
792        let input: DeriveInput = syn::parse_quote! {
793            enum Foo { A, B }
794        };
795        let result = panel::derive_panel_impl(&input);
796        assert!(result.is_err());
797        assert!(result
798            .unwrap_err()
799            .to_string()
800            .contains("does not support enums"));
801    }
802
803    #[test]
804    fn test_derive_panel_rejects_unknown_panel_attr() {
805        let input: DeriveInput = syn::parse_quote! {
806            #[panel(nonexistent = "value")]
807            struct Rec {
808                a: String,
809            }
810        };
811        let result = panel::derive_panel_impl(&input);
812        assert!(result.is_err());
813        assert!(result
814            .unwrap_err()
815            .to_string()
816            .contains("unknown panel attribute"),);
817    }
818
819    #[test]
820    fn test_derive_panel_rejects_unknown_field_attr() {
821        let input: DeriveInput = syn::parse_quote! {
822            struct Rec {
823                #[field(nonexistent = "value")]
824                a: String,
825            }
826        };
827        let result = panel::derive_panel_impl(&input);
828        assert!(result.is_err());
829        assert!(result
830            .unwrap_err()
831            .to_string()
832            .contains("unknown field attribute"),);
833    }
834
835    #[test]
836    fn test_derive_panel_rejects_invalid_box_style() {
837        let input: DeriveInput = syn::parse_quote! {
838            #[panel(box_style = "FANCY")]
839            struct Rec {
840                a: String,
841            }
842        };
843        let result = panel::derive_panel_impl(&input);
844        assert!(result.is_err());
845        assert!(result
846            .unwrap_err()
847            .to_string()
848            .contains("unknown box_style"));
849    }
850
851    #[test]
852    fn test_derive_panel_title_style() {
853        let input: DeriveInput = syn::parse_quote! {
854            #[panel(title = "Info", title_style = "bold cyan")]
855            struct Info {
856                a: String,
857            }
858        };
859        let result = panel::derive_panel_impl(&input);
860        assert!(result.is_ok());
861        let tokens = result.unwrap().to_string();
862        assert!(
863            tokens.contains("bold cyan"),
864            "should apply title_style as markup"
865        );
866        assert!(tokens.contains("\"Info\""), "should contain title text");
867    }
868
869    // -- panel::PanelAttr parsing -------------------------------------------------
870
871    #[test]
872    fn test_parse_panel_attr_str() {
873        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "My Panel" };
874        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
875        assert_eq!(attr.key, "title");
876        match attr.value {
877            panel::PanelAttrValue::Str(s) => assert_eq!(s.value(), "My Panel"),
878            _ => panic!("expected Str"),
879        }
880    }
881
882    #[test]
883    fn test_parse_panel_attr_bool() {
884        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand = false };
885        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
886        assert_eq!(attr.key, "expand");
887        match attr.value {
888            panel::PanelAttrValue::Bool(b) => assert!(!b.value),
889            _ => panic!("expected Bool"),
890        }
891    }
892
893    #[test]
894    fn test_parse_panel_attr_flag() {
895        let tokens: proc_macro2::TokenStream = syn::parse_quote! { highlight };
896        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
897        assert_eq!(attr.key, "highlight");
898        matches!(attr.value, panel::PanelAttrValue::Flag);
899    }
900
901    // -- panel::FieldAttr parsing -------------------------------------------------
902
903    #[test]
904    fn test_parse_field_attr_str() {
905        let tokens: proc_macro2::TokenStream = syn::parse_quote! { label = "Host" };
906        let attr: panel::FieldAttr = syn::parse2(tokens).unwrap();
907        assert_eq!(attr.key, "label");
908        match attr.value {
909            panel::FieldAttrValue::Str(s) => assert_eq!(s.value(), "Host"),
910            _ => panic!("expected Str"),
911        }
912    }
913
914    #[test]
915    fn test_parse_field_attr_flag() {
916        let tokens: proc_macro2::TokenStream = syn::parse_quote! { skip };
917        let attr: panel::FieldAttr = syn::parse2(tokens).unwrap();
918        assert_eq!(attr.key, "skip");
919        matches!(attr.value, panel::FieldAttrValue::Flag);
920    }
921
922    #[test]
923    fn test_panel_expect_str_ok() {
924        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "hello" };
925        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
926        let result = panel::panel_expect_str(&attr, "title");
927        assert!(result.is_ok());
928        assert_eq!(result.unwrap().value(), "hello");
929    }
930
931    #[test]
932    fn test_panel_expect_str_wrong_type() {
933        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = true };
934        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
935        let result = panel::panel_expect_str(&attr, "title");
936        assert!(result.is_err());
937    }
938
939    #[test]
940    fn test_panel_expect_bool_flag() {
941        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand };
942        let attr: panel::PanelAttr = syn::parse2(tokens).unwrap();
943        let result = panel::panel_expect_bool(&attr, "expand");
944        assert!(result.is_ok());
945        assert!(result.unwrap().value);
946    }
947
948    #[test]
949    fn test_field_expect_str_ok() {
950        let tokens: proc_macro2::TokenStream = syn::parse_quote! { label = "Name" };
951        let attr: panel::FieldAttr = syn::parse2(tokens).unwrap();
952        let result = panel::field_expect_str(&attr, "label");
953        assert!(result.is_ok());
954        assert_eq!(result.unwrap().value(), "Name");
955    }
956
957    #[test]
958    fn test_field_expect_bool_flag() {
959        let tokens: proc_macro2::TokenStream = syn::parse_quote! { skip };
960        let attr: panel::FieldAttr = syn::parse2(tokens).unwrap();
961        let result = panel::field_expect_bool(&attr, "skip");
962        assert!(result.is_ok());
963        assert!(result.unwrap().value);
964    }
965
966    // -- Tree derive tests -------------------------------------------------
967
968    #[test]
969    fn test_derive_tree_basic() {
970        let input: DeriveInput = syn::parse_quote! {
971            struct FileEntry {
972                #[tree(label)]
973                name: String,
974                #[tree(children)]
975                entries: Vec<FileEntry>,
976            }
977        };
978        let result = tree::derive_tree_impl(&input);
979        assert!(
980            result.is_ok(),
981            "derive_tree_impl failed: {:?}",
982            result.err()
983        );
984        let tokens = result.unwrap().to_string();
985        assert!(tokens.contains("to_tree"), "should generate to_tree method");
986        assert!(tokens.contains("Tree"), "should reference Tree type");
987        assert!(tokens.contains("name"), "should use label field 'name'");
988        assert!(
989            tokens.contains("entries"),
990            "should use children field 'entries'"
991        );
992        assert!(tokens.contains("children"), "should push to children vec");
993    }
994
995    #[test]
996    fn test_derive_tree_with_style() {
997        let input: DeriveInput = syn::parse_quote! {
998            #[tree(style = "bold", guide_style = "dim cyan")]
999            struct FileEntry {
1000                #[tree(label)]
1001                name: String,
1002                #[tree(children)]
1003                entries: Vec<FileEntry>,
1004            }
1005        };
1006        let result = tree::derive_tree_impl(&input);
1007        assert!(
1008            result.is_ok(),
1009            "derive_tree_impl failed: {:?}",
1010            result.err()
1011        );
1012        let tokens = result.unwrap().to_string();
1013        assert!(
1014            tokens.contains("\"bold\""),
1015            "should contain style string 'bold'"
1016        );
1017        assert!(
1018            tokens.contains("\"dim cyan\""),
1019            "should contain guide_style string 'dim cyan'"
1020        );
1021        assert!(
1022            tokens.contains("Style :: parse"),
1023            "should call Style::parse"
1024        );
1025    }
1026
1027    #[test]
1028    fn test_derive_tree_with_leaf() {
1029        let input: DeriveInput = syn::parse_quote! {
1030            struct FileEntry {
1031                #[tree(label)]
1032                name: String,
1033                #[tree(children)]
1034                entries: Vec<FileEntry>,
1035                #[tree(leaf)]
1036                size: u64,
1037                #[tree(leaf)]
1038                permissions: String,
1039            }
1040        };
1041        let result = tree::derive_tree_impl(&input);
1042        assert!(
1043            result.is_ok(),
1044            "derive_tree_impl failed: {:?}",
1045            result.err()
1046        );
1047        let tokens = result.unwrap().to_string();
1048        assert!(tokens.contains("to_tree"), "should generate to_tree method");
1049        assert!(
1050            tokens.contains("\"Size\""),
1051            "should contain leaf label 'Size'"
1052        );
1053        assert!(
1054            tokens.contains("\"Permissions\""),
1055            "should contain leaf label 'Permissions'"
1056        );
1057        assert!(
1058            tokens.contains("self . size"),
1059            "should reference leaf field 'size'"
1060        );
1061        assert!(
1062            tokens.contains("self . permissions"),
1063            "should reference leaf field 'permissions'"
1064        );
1065    }
1066
1067    #[test]
1068    fn test_derive_tree_missing_label() {
1069        let input: DeriveInput = syn::parse_quote! {
1070            struct FileEntry {
1071                name: String,
1072                #[tree(children)]
1073                entries: Vec<FileEntry>,
1074            }
1075        };
1076        let result = tree::derive_tree_impl(&input);
1077        assert!(result.is_err(), "should error when no #[tree(label)] field");
1078        let err_msg = result.unwrap_err().to_string();
1079        assert!(
1080            err_msg.contains("tree(label)"),
1081            "error should mention tree(label), got: {}",
1082            err_msg
1083        );
1084    }
1085
1086    #[test]
1087    fn test_derive_tree_missing_children() {
1088        let input: DeriveInput = syn::parse_quote! {
1089            struct FileEntry {
1090                #[tree(label)]
1091                name: String,
1092                entries: Vec<FileEntry>,
1093            }
1094        };
1095        let result = tree::derive_tree_impl(&input);
1096        assert!(
1097            result.is_err(),
1098            "should error when no #[tree(children)] field"
1099        );
1100        let err_msg = result.unwrap_err().to_string();
1101        assert!(
1102            err_msg.contains("tree(children)"),
1103            "error should mention tree(children), got: {}",
1104            err_msg
1105        );
1106    }
1107
1108    #[test]
1109    fn test_derive_tree_rejects_enum() {
1110        let input: DeriveInput = syn::parse_quote! {
1111            enum Foo { A, B }
1112        };
1113        let result = tree::derive_tree_impl(&input);
1114        assert!(result.is_err());
1115        assert!(result
1116            .unwrap_err()
1117            .to_string()
1118            .contains("does not support enums"));
1119    }
1120
1121    #[test]
1122    fn test_derive_tree_rejects_unknown_tree_attr() {
1123        let input: DeriveInput = syn::parse_quote! {
1124            #[tree(nonexistent = "value")]
1125            struct FileEntry {
1126                #[tree(label)]
1127                name: String,
1128                #[tree(children)]
1129                entries: Vec<FileEntry>,
1130            }
1131        };
1132        let result = tree::derive_tree_impl(&input);
1133        assert!(result.is_err());
1134        assert!(result
1135            .unwrap_err()
1136            .to_string()
1137            .contains("unknown tree attribute"),);
1138    }
1139
1140    #[test]
1141    fn test_derive_tree_rejects_unknown_field_role() {
1142        let input: DeriveInput = syn::parse_quote! {
1143            struct FileEntry {
1144                #[tree(label)]
1145                name: String,
1146                #[tree(children)]
1147                entries: Vec<FileEntry>,
1148                #[tree(bogus)]
1149                size: u64,
1150            }
1151        };
1152        let result = tree::derive_tree_impl(&input);
1153        assert!(result.is_err());
1154        assert!(result
1155            .unwrap_err()
1156            .to_string()
1157            .contains("unknown tree field attribute"),);
1158    }
1159
1160    #[test]
1161    fn test_derive_tree_duplicate_label() {
1162        let input: DeriveInput = syn::parse_quote! {
1163            struct FileEntry {
1164                #[tree(label)]
1165                name: String,
1166                #[tree(label)]
1167                title: String,
1168                #[tree(children)]
1169                entries: Vec<FileEntry>,
1170            }
1171        };
1172        let result = tree::derive_tree_impl(&input);
1173        assert!(result.is_err());
1174        assert!(result.unwrap_err().to_string().contains("only one field"),);
1175    }
1176
1177    #[test]
1178    fn test_derive_tree_ignores_unannotated_fields() {
1179        let input: DeriveInput = syn::parse_quote! {
1180            struct FileEntry {
1181                #[tree(label)]
1182                name: String,
1183                #[tree(children)]
1184                entries: Vec<FileEntry>,
1185                ignored_field: u64,
1186                another_ignored: String,
1187            }
1188        };
1189        let result = tree::derive_tree_impl(&input);
1190        assert!(
1191            result.is_ok(),
1192            "unannotated fields should be silently ignored"
1193        );
1194        let tokens = result.unwrap().to_string();
1195        // Ignored fields should not appear in the output.
1196        assert!(
1197            !tokens.contains("ignored_field"),
1198            "ignored_field should not appear"
1199        );
1200        assert!(
1201            !tokens.contains("another_ignored"),
1202            "another_ignored should not appear"
1203        );
1204    }
1205
1206    // -- tree::TreeAttr parsing --------------------------------------------------
1207
1208    #[test]
1209    fn test_parse_tree_attr_str() {
1210        let tokens: proc_macro2::TokenStream = syn::parse_quote! { style = "bold" };
1211        let attr: tree::TreeAttr = syn::parse2(tokens).unwrap();
1212        assert_eq!(attr.key, "style");
1213        match attr.value {
1214            tree::TreeAttrValue::Str(s) => assert_eq!(s.value(), "bold"),
1215            _ => panic!("expected Str"),
1216        }
1217    }
1218
1219    #[test]
1220    fn test_parse_tree_attr_flag() {
1221        let tokens: proc_macro2::TokenStream = syn::parse_quote! { style };
1222        let attr: tree::TreeAttr = syn::parse2(tokens).unwrap();
1223        assert_eq!(attr.key, "style");
1224        matches!(attr.value, tree::TreeAttrValue::Flag);
1225    }
1226
1227    #[test]
1228    fn test_tree_expect_str_ok() {
1229        let tokens: proc_macro2::TokenStream = syn::parse_quote! { guide_style = "dim cyan" };
1230        let attr: tree::TreeAttr = syn::parse2(tokens).unwrap();
1231        let result = tree::tree_expect_str(&attr, "guide_style");
1232        assert!(result.is_ok());
1233        assert_eq!(result.unwrap().value(), "dim cyan");
1234    }
1235
1236    #[test]
1237    fn test_tree_expect_str_wrong_type() {
1238        let tokens: proc_macro2::TokenStream = syn::parse_quote! { style };
1239        let attr: tree::TreeAttr = syn::parse2(tokens).unwrap();
1240        let result = tree::tree_expect_str(&attr, "style");
1241        assert!(result.is_err());
1242    }
1243
1244    // -- Renderable derive tests -------------------------------------------
1245
1246    #[test]
1247    fn test_derive_renderable_via_panel() {
1248        let input: DeriveInput = syn::parse_quote! {
1249            #[renderable(via = "panel")]
1250            struct Config {
1251                host: String,
1252                port: u16,
1253            }
1254        };
1255        let result = renderable::derive_renderable_impl(&input);
1256        assert!(
1257            result.is_ok(),
1258            "derive_renderable_impl failed: {:?}",
1259            result.err()
1260        );
1261        let tokens = result.unwrap().to_string();
1262        assert!(
1263            tokens.contains("Renderable"),
1264            "should implement Renderable trait"
1265        );
1266        assert!(
1267            tokens.contains("gilt_console"),
1268            "should generate gilt_console method"
1269        );
1270        assert!(tokens.contains("to_panel"), "should delegate to to_panel()");
1271        assert!(
1272            !tokens.contains("to_tree"),
1273            "should not reference to_tree()"
1274        );
1275    }
1276
1277    #[test]
1278    fn test_derive_renderable_via_tree() {
1279        let input: DeriveInput = syn::parse_quote! {
1280            #[renderable(via = "tree")]
1281            struct FileEntry {
1282                name: String,
1283                entries: Vec<String>,
1284            }
1285        };
1286        let result = renderable::derive_renderable_impl(&input);
1287        assert!(
1288            result.is_ok(),
1289            "derive_renderable_impl failed: {:?}",
1290            result.err()
1291        );
1292        let tokens = result.unwrap().to_string();
1293        assert!(
1294            tokens.contains("Renderable"),
1295            "should implement Renderable trait"
1296        );
1297        assert!(
1298            tokens.contains("gilt_console"),
1299            "should generate gilt_console method"
1300        );
1301        assert!(tokens.contains("to_tree"), "should delegate to to_tree()");
1302        assert!(
1303            !tokens.contains("to_panel"),
1304            "should not reference to_panel()"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_derive_renderable_default() {
1310        // No #[renderable(...)] attribute at all — defaults to panel.
1311        let input: DeriveInput = syn::parse_quote! {
1312            struct Simple {
1313                name: String,
1314                value: u32,
1315            }
1316        };
1317        let result = renderable::derive_renderable_impl(&input);
1318        assert!(
1319            result.is_ok(),
1320            "derive_renderable_impl should succeed with no attrs"
1321        );
1322        let tokens = result.unwrap().to_string();
1323        assert!(
1324            tokens.contains("Renderable"),
1325            "should implement Renderable trait"
1326        );
1327        assert!(
1328            tokens.contains("to_panel"),
1329            "default delegation should be to_panel()"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_derive_renderable_rejects_unknown_via() {
1335        let input: DeriveInput = syn::parse_quote! {
1336            #[renderable(via = "table")]
1337            struct Rec {
1338                a: String,
1339            }
1340        };
1341        let result = renderable::derive_renderable_impl(&input);
1342        assert!(result.is_err(), "should reject unknown via value");
1343        let err_msg = result.unwrap_err().to_string();
1344        assert!(
1345            err_msg.contains("unknown renderable via"),
1346            "error should mention unknown via, got: {}",
1347            err_msg
1348        );
1349    }
1350
1351    #[test]
1352    fn test_derive_renderable_rejects_enum() {
1353        let input: DeriveInput = syn::parse_quote! {
1354            enum Foo { A, B }
1355        };
1356        let result = renderable::derive_renderable_impl(&input);
1357        assert!(result.is_err());
1358        assert!(result
1359            .unwrap_err()
1360            .to_string()
1361            .contains("does not support enums"));
1362    }
1363
1364    #[test]
1365    fn test_derive_renderable_rejects_unknown_attr() {
1366        let input: DeriveInput = syn::parse_quote! {
1367            #[renderable(nonexistent = "value")]
1368            struct Rec {
1369                a: String,
1370            }
1371        };
1372        let result = renderable::derive_renderable_impl(&input);
1373        assert!(result.is_err());
1374        assert!(result
1375            .unwrap_err()
1376            .to_string()
1377            .contains("unknown renderable attribute"),);
1378    }
1379
1380    // -- renderable::RenderableAttr parsing --------------------------------------------
1381
1382    #[test]
1383    fn test_parse_renderable_attr_str() {
1384        let tokens: proc_macro2::TokenStream = syn::parse_quote! { via = "panel" };
1385        let attr: renderable::RenderableAttr = syn::parse2(tokens).unwrap();
1386        assert_eq!(attr.key, "via");
1387        match attr.value {
1388            renderable::RenderableAttrValue::Str(s) => assert_eq!(s.value(), "panel"),
1389        }
1390    }
1391
1392    #[test]
1393    fn test_renderable_expect_str_ok() {
1394        let tokens: proc_macro2::TokenStream = syn::parse_quote! { via = "tree" };
1395        let attr: renderable::RenderableAttr = syn::parse2(tokens).unwrap();
1396        let result = renderable::renderable_expect_str(&attr, "via");
1397        assert!(result.is_ok());
1398        assert_eq!(result.unwrap().value(), "tree");
1399    }
1400
1401    // -- Columns derive tests ----------------------------------------------
1402
1403    #[test]
1404    fn test_derive_columns_basic() {
1405        let input: DeriveInput = syn::parse_quote! {
1406            struct ProjectCard {
1407                name: String,
1408                status: String,
1409            }
1410        };
1411        let result = columns::derive_columns_impl(&input);
1412        assert!(
1413            result.is_ok(),
1414            "derive_columns_impl failed: {:?}",
1415            result.err()
1416        );
1417        let tokens = result.unwrap().to_string();
1418        assert!(tokens.contains("to_card"), "should generate to_card method");
1419        assert!(
1420            tokens.contains("to_columns"),
1421            "should generate to_columns method"
1422        );
1423        assert!(
1424            tokens.contains("\"Name\""),
1425            "should contain default label 'Name'"
1426        );
1427        assert!(
1428            tokens.contains("\"Status\""),
1429            "should contain default label 'Status'"
1430        );
1431        assert!(tokens.contains("Panel"), "should reference Panel type");
1432        assert!(tokens.contains("Columns"), "should reference Columns type");
1433        // Default title should be the struct name.
1434        assert!(
1435            tokens.contains("\"ProjectCard\""),
1436            "default card title should be struct name"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_derive_columns_with_attrs() {
1442        let input: DeriveInput = syn::parse_quote! {
1443            #[columns(
1444                column_count = 3,
1445                equal = true,
1446                expand = true,
1447                padding = 2,
1448                title = "My Projects"
1449            )]
1450            struct ProjectCard {
1451                #[field(label = "Project", style = "bold cyan")]
1452                name: String,
1453                #[field(label = "Status")]
1454                status: String,
1455            }
1456        };
1457        let result = columns::derive_columns_impl(&input);
1458        assert!(
1459            result.is_ok(),
1460            "derive_columns_impl failed: {:?}",
1461            result.err()
1462        );
1463        let tokens = result.unwrap().to_string();
1464        assert!(tokens.contains("to_card"), "should generate to_card method");
1465        assert!(
1466            tokens.contains("to_columns"),
1467            "should generate to_columns method"
1468        );
1469        assert!(
1470            tokens.contains("\"Project\""),
1471            "should use custom label 'Project'"
1472        );
1473        assert!(
1474            tokens.contains("bold cyan"),
1475            "should contain field style markup"
1476        );
1477        assert!(
1478            tokens.contains("\"Status\""),
1479            "should use custom label 'Status'"
1480        );
1481        assert!(tokens.contains("equal"), "should set equal");
1482        assert!(tokens.contains("expand"), "should set expand");
1483        assert!(
1484            tokens.contains("width"),
1485            "should set width from column_count"
1486        );
1487        assert!(tokens.contains("\"My Projects\""), "should contain title");
1488    }
1489
1490    #[test]
1491    fn test_derive_columns_skip_field() {
1492        let input: DeriveInput = syn::parse_quote! {
1493            struct Card {
1494                visible: String,
1495                #[field(skip)]
1496                hidden: u64,
1497                also_visible: i32,
1498            }
1499        };
1500        let result = columns::derive_columns_impl(&input);
1501        assert!(result.is_ok());
1502        let tokens = result.unwrap().to_string();
1503        assert!(
1504            tokens.contains("\"Visible\""),
1505            "should include visible field"
1506        );
1507        assert!(
1508            tokens.contains("\"Also Visible\""),
1509            "should include also_visible field"
1510        );
1511        // The hidden field should not appear.
1512        assert!(!tokens.contains("\"Hidden\""), "should skip hidden field");
1513        assert!(
1514            !tokens.contains("hidden"),
1515            "hidden field ident should not appear"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_derive_columns_rejects_enum() {
1521        let input: DeriveInput = syn::parse_quote! {
1522            enum Foo { A, B }
1523        };
1524        let result = columns::derive_columns_impl(&input);
1525        assert!(result.is_err());
1526        assert!(result
1527            .unwrap_err()
1528            .to_string()
1529            .contains("does not support enums"));
1530    }
1531
1532    // -- columns::ColumnsAttr parsing -----------------------------------------------
1533
1534    #[test]
1535    fn test_parse_columns_attr_str() {
1536        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "My Cols" };
1537        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1538        assert_eq!(attr.key, "title");
1539        match attr.value {
1540            columns::ColumnsAttrValue::Str(s) => assert_eq!(s.value(), "My Cols"),
1541            _ => panic!("expected Str"),
1542        }
1543    }
1544
1545    #[test]
1546    fn test_parse_columns_attr_bool() {
1547        let tokens: proc_macro2::TokenStream = syn::parse_quote! { equal = true };
1548        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1549        assert_eq!(attr.key, "equal");
1550        match attr.value {
1551            columns::ColumnsAttrValue::Bool(b) => assert!(b.value),
1552            _ => panic!("expected Bool"),
1553        }
1554    }
1555
1556    #[test]
1557    fn test_parse_columns_attr_int() {
1558        let tokens: proc_macro2::TokenStream = syn::parse_quote! { column_count = 4 };
1559        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1560        assert_eq!(attr.key, "column_count");
1561        match attr.value {
1562            columns::ColumnsAttrValue::Int(i) => assert_eq!(i.base10_parse::<usize>().unwrap(), 4),
1563            _ => panic!("expected Int"),
1564        }
1565    }
1566
1567    #[test]
1568    fn test_parse_columns_attr_flag() {
1569        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand };
1570        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1571        assert_eq!(attr.key, "expand");
1572        matches!(attr.value, columns::ColumnsAttrValue::Flag);
1573    }
1574
1575    #[test]
1576    fn test_columns_expect_str_ok() {
1577        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "hello" };
1578        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1579        let result = columns::columns_expect_str(&attr, "title");
1580        assert!(result.is_ok());
1581        assert_eq!(result.unwrap().value(), "hello");
1582    }
1583
1584    #[test]
1585    fn test_columns_expect_bool_flag() {
1586        let tokens: proc_macro2::TokenStream = syn::parse_quote! { expand };
1587        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1588        let result = columns::columns_expect_bool(&attr, "expand");
1589        assert!(result.is_ok());
1590        assert!(result.unwrap().value);
1591    }
1592
1593    #[test]
1594    fn test_columns_expect_int_ok() {
1595        let tokens: proc_macro2::TokenStream = syn::parse_quote! { padding = 3 };
1596        let attr: columns::ColumnsAttr = syn::parse2(tokens).unwrap();
1597        let result = columns::columns_expect_int(&attr, "padding");
1598        assert!(result.is_ok());
1599        assert_eq!(result.unwrap().base10_parse::<usize>().unwrap(), 3);
1600    }
1601
1602    #[test]
1603    fn test_derive_columns_rejects_unknown_columns_attr() {
1604        let input: DeriveInput = syn::parse_quote! {
1605            #[columns(nonexistent = "value")]
1606            struct Rec {
1607                a: String,
1608            }
1609        };
1610        let result = columns::derive_columns_impl(&input);
1611        assert!(result.is_err());
1612        assert!(result
1613            .unwrap_err()
1614            .to_string()
1615            .contains("unknown columns attribute"),);
1616    }
1617
1618    #[test]
1619    fn test_derive_columns_rejects_unknown_field_attr() {
1620        let input: DeriveInput = syn::parse_quote! {
1621            struct Rec {
1622                #[field(nonexistent = "value")]
1623                a: String,
1624            }
1625        };
1626        let result = columns::derive_columns_impl(&input);
1627        assert!(result.is_err());
1628        assert!(result
1629            .unwrap_err()
1630            .to_string()
1631            .contains("unknown field attribute"),);
1632    }
1633
1634    // -- Rule derive -------------------------------------------------------
1635
1636    #[test]
1637    fn test_derive_rule_basic() {
1638        let input: DeriveInput = syn::parse_quote! {
1639            struct Section {
1640                heading: String,
1641            }
1642        };
1643        let result = rule::derive_rule_impl(&input);
1644        assert!(
1645            result.is_ok(),
1646            "derive_rule_impl failed: {:?}",
1647            result.err()
1648        );
1649        let tokens = result.unwrap().to_string();
1650        assert!(tokens.contains("to_rule"), "should generate to_rule method");
1651        assert!(tokens.contains("Rule"), "should reference Rule type");
1652        assert!(
1653            tokens.contains("with_title"),
1654            "should use with_title constructor"
1655        );
1656        // Default title should be the struct name.
1657        assert!(
1658            tokens.contains("\"Section\""),
1659            "default title should be struct name"
1660        );
1661    }
1662
1663    #[test]
1664    fn test_derive_rule_with_style() {
1665        let input: DeriveInput = syn::parse_quote! {
1666            #[rule(style = "bold blue")]
1667            struct Divider {
1668                label: String,
1669            }
1670        };
1671        let result = rule::derive_rule_impl(&input);
1672        assert!(
1673            result.is_ok(),
1674            "derive_rule_impl failed: {:?}",
1675            result.err()
1676        );
1677        let tokens = result.unwrap().to_string();
1678        assert!(tokens.contains("to_rule"), "should generate to_rule method");
1679        assert!(
1680            tokens.contains("Style :: parse"),
1681            "should parse style string"
1682        );
1683        assert!(
1684            tokens.contains("\"bold blue\""),
1685            "should contain style value"
1686        );
1687    }
1688
1689    #[test]
1690    fn test_derive_rule_with_characters() {
1691        let input: DeriveInput = syn::parse_quote! {
1692            #[rule(characters = "=")]
1693            struct Break {
1694                text: String,
1695            }
1696        };
1697        let result = rule::derive_rule_impl(&input);
1698        assert!(
1699            result.is_ok(),
1700            "derive_rule_impl failed: {:?}",
1701            result.err()
1702        );
1703        let tokens = result.unwrap().to_string();
1704        assert!(
1705            tokens.contains("characters"),
1706            "should call characters method"
1707        );
1708        assert!(tokens.contains("\"=\""), "should contain custom character");
1709    }
1710
1711    #[test]
1712    fn test_derive_rule_with_align() {
1713        let input: DeriveInput = syn::parse_quote! {
1714            #[rule(align = "left")]
1715            struct Header {
1716                text: String,
1717            }
1718        };
1719        let result = rule::derive_rule_impl(&input);
1720        assert!(
1721            result.is_ok(),
1722            "derive_rule_impl failed: {:?}",
1723            result.err()
1724        );
1725        let tokens = result.unwrap().to_string();
1726        assert!(tokens.contains("align"), "should call align method");
1727        assert!(tokens.contains("Left"), "should contain Left variant");
1728    }
1729
1730    #[test]
1731    fn test_derive_rule_title_field() {
1732        let input: DeriveInput = syn::parse_quote! {
1733            struct Section {
1734                #[rule(title)]
1735                heading: String,
1736                extra: u32,
1737            }
1738        };
1739        let result = rule::derive_rule_impl(&input);
1740        assert!(
1741            result.is_ok(),
1742            "derive_rule_impl failed: {:?}",
1743            result.err()
1744        );
1745        let tokens = result.unwrap().to_string();
1746        assert!(tokens.contains("to_rule"), "should generate to_rule method");
1747        // Should use the field `heading` as the title source.
1748        assert!(tokens.contains("heading"), "should reference heading field");
1749        assert!(
1750            tokens.contains("to_string"),
1751            "should call to_string on field"
1752        );
1753        // Should NOT fall back to struct name.
1754        assert!(
1755            !tokens.contains("\"Section\""),
1756            "should not use struct name as title"
1757        );
1758    }
1759
1760    #[test]
1761    fn test_derive_rule_rejects_enum() {
1762        let input: DeriveInput = syn::parse_quote! {
1763            enum Foo { A, B }
1764        };
1765        let result = rule::derive_rule_impl(&input);
1766        assert!(result.is_err());
1767        assert!(result
1768            .unwrap_err()
1769            .to_string()
1770            .contains("does not support enums"));
1771    }
1772
1773    // -- rule::RuleAttr parsing --------------------------------------------------
1774
1775    #[test]
1776    fn test_parse_rule_attr_str() {
1777        let tokens: proc_macro2::TokenStream = syn::parse_quote! { style = "bold red" };
1778        let attr: rule::RuleAttr = syn::parse2(tokens).unwrap();
1779        assert_eq!(attr.key, "style");
1780        match attr.value {
1781            rule::RuleAttrValue::Str(s) => assert_eq!(s.value(), "bold red"),
1782        }
1783    }
1784
1785    #[test]
1786    fn test_rule_expect_str_ok() {
1787        let tokens: proc_macro2::TokenStream = syn::parse_quote! { characters = "─" };
1788        let attr: rule::RuleAttr = syn::parse2(tokens).unwrap();
1789        let result = rule::rule_expect_str(&attr, "characters");
1790        assert!(result.is_ok());
1791        assert_eq!(result.unwrap().value(), "─");
1792    }
1793
1794    // -- Inspect derive tests ----------------------------------------------
1795
1796    #[test]
1797    fn test_derive_inspect_basic() {
1798        let input: DeriveInput = syn::parse_quote! {
1799            struct Config {
1800                host: String,
1801                port: u16,
1802            }
1803        };
1804        let result = inspect::derive_inspect_impl(&input);
1805        assert!(
1806            result.is_ok(),
1807            "derive_inspect_impl failed: {:?}",
1808            result.err()
1809        );
1810        let tokens = result.unwrap().to_string();
1811        assert!(
1812            tokens.contains("to_inspect"),
1813            "should generate to_inspect method"
1814        );
1815        assert!(tokens.contains("Inspect"), "should reference Inspect type");
1816        assert!(tokens.contains("Debug"), "should have Debug bound");
1817    }
1818
1819    #[test]
1820    fn test_derive_inspect_with_title() {
1821        let input: DeriveInput = syn::parse_quote! {
1822            #[inspect(title = "Server Info")]
1823            struct Config {
1824                host: String,
1825                port: u16,
1826            }
1827        };
1828        let result = inspect::derive_inspect_impl(&input);
1829        assert!(
1830            result.is_ok(),
1831            "derive_inspect_impl failed: {:?}",
1832            result.err()
1833        );
1834        let tokens = result.unwrap().to_string();
1835        assert!(
1836            tokens.contains("\"Server Info\""),
1837            "should contain custom title"
1838        );
1839        assert!(tokens.contains("with_title"), "should call with_title");
1840    }
1841
1842    #[test]
1843    fn test_derive_inspect_with_all_attrs() {
1844        let input: DeriveInput = syn::parse_quote! {
1845            #[inspect(title = "My Widget", label = "web-01", doc = "A web server", pretty = false)]
1846            struct Server {
1847                host: String,
1848                cpu: f32,
1849            }
1850        };
1851        let result = inspect::derive_inspect_impl(&input);
1852        assert!(
1853            result.is_ok(),
1854            "derive_inspect_impl failed: {:?}",
1855            result.err()
1856        );
1857        let tokens = result.unwrap().to_string();
1858        assert!(tokens.contains("with_title"), "should call with_title");
1859        assert!(tokens.contains("with_label"), "should call with_label");
1860        assert!(tokens.contains("with_doc"), "should call with_doc");
1861        assert!(tokens.contains("with_pretty"), "should call with_pretty");
1862    }
1863
1864    #[test]
1865    fn test_derive_inspect_pretty_false() {
1866        let input: DeriveInput = syn::parse_quote! {
1867            #[inspect(pretty = false)]
1868            struct Config {
1869                host: String,
1870            }
1871        };
1872        let result = inspect::derive_inspect_impl(&input);
1873        assert!(result.is_ok());
1874        let tokens = result.unwrap().to_string();
1875        assert!(tokens.contains("with_pretty"), "should call with_pretty");
1876    }
1877
1878    #[test]
1879    fn test_derive_inspect_rejects_enum() {
1880        let input: DeriveInput = syn::parse_quote! {
1881            enum Foo { A, B }
1882        };
1883        let result = inspect::derive_inspect_impl(&input);
1884        assert!(result.is_err());
1885        assert!(result
1886            .unwrap_err()
1887            .to_string()
1888            .contains("does not support enums"));
1889    }
1890
1891    #[test]
1892    fn test_derive_inspect_rejects_unknown_attr() {
1893        let input: DeriveInput = syn::parse_quote! {
1894            #[inspect(nonexistent = "value")]
1895            struct Rec {
1896                a: String,
1897            }
1898        };
1899        let result = inspect::derive_inspect_impl(&input);
1900        assert!(result.is_err());
1901        assert!(result
1902            .unwrap_err()
1903            .to_string()
1904            .contains("unknown inspect attribute"),);
1905    }
1906
1907    // -- inspect::InspectAttr parsing -----------------------------------------------
1908
1909    #[test]
1910    fn test_parse_inspect_attr_str() {
1911        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "My Inspect" };
1912        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1913        assert_eq!(attr.key, "title");
1914        match attr.value {
1915            inspect::InspectAttrValue::Str(s) => assert_eq!(s.value(), "My Inspect"),
1916            _ => panic!("expected Str"),
1917        }
1918    }
1919
1920    #[test]
1921    fn test_parse_inspect_attr_bool() {
1922        let tokens: proc_macro2::TokenStream = syn::parse_quote! { pretty = false };
1923        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1924        assert_eq!(attr.key, "pretty");
1925        match attr.value {
1926            inspect::InspectAttrValue::Bool(b) => assert!(!b.value),
1927            _ => panic!("expected Bool"),
1928        }
1929    }
1930
1931    #[test]
1932    fn test_inspect_expect_str_ok() {
1933        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = "hello" };
1934        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1935        let result = inspect::inspect_expect_str(&attr, "title");
1936        assert!(result.is_ok());
1937        assert_eq!(result.unwrap().value(), "hello");
1938    }
1939
1940    #[test]
1941    fn test_inspect_expect_str_wrong_type() {
1942        let tokens: proc_macro2::TokenStream = syn::parse_quote! { title = true };
1943        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1944        let result = inspect::inspect_expect_str(&attr, "title");
1945        assert!(result.is_err());
1946    }
1947
1948    #[test]
1949    fn test_inspect_expect_bool_flag() {
1950        let tokens: proc_macro2::TokenStream = syn::parse_quote! { pretty };
1951        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1952        let result = inspect::inspect_expect_bool(&attr, "pretty");
1953        assert!(result.is_ok());
1954        assert!(result.unwrap().value);
1955    }
1956
1957    #[test]
1958    fn test_inspect_expect_bool_ok() {
1959        let tokens: proc_macro2::TokenStream = syn::parse_quote! { pretty = false };
1960        let attr: inspect::InspectAttr = syn::parse2(tokens).unwrap();
1961        let result = inspect::inspect_expect_bool(&attr, "pretty");
1962        assert!(result.is_ok());
1963        assert!(!result.unwrap().value);
1964    }
1965
1966    // ===================================================================
1967    // insta snapshot tests — exact-token guards on each derive's output
1968    // ===================================================================
1969    //
1970    // These guard against accidental codegen drift during refactors.
1971    // To regenerate after an intentional change:
1972    //
1973    //     INSTA_UPDATE=always cargo test -p gilt-derive --lib expand_
1974    //
1975    // Snapshots live at crates/gilt-derive/src/snapshots/.
1976
1977    /// Pretty-print a TokenStream so snapshots are readable. We use
1978    /// `prettyplease` only here; if it's unavailable we fall back to the
1979    /// raw string form (still stable since `quote!` produces deterministic
1980    /// output for given inputs).
1981    fn render_tokens(ts: &proc_macro2::TokenStream) -> String {
1982        // Try prettyplease via syn::parse2 → prettyplease::unparse. If the
1983        // tokens don't form a complete File (some derives emit bare items),
1984        // fall back to the raw string.
1985        match syn::parse2::<syn::File>(ts.clone()) {
1986            Ok(file) => prettyplease_or_raw(&file, ts),
1987            Err(_) => ts.to_string(),
1988        }
1989    }
1990
1991    fn prettyplease_or_raw(file: &syn::File, ts: &proc_macro2::TokenStream) -> String {
1992        // prettyplease isn't a hard dep — keep the test resilient if it's
1993        // ever removed. quote!-based output is already deterministic so the
1994        // raw form is acceptable.
1995        let _ = file;
1996        ts.to_string()
1997    }
1998
1999    #[test]
2000    fn expand_table_minimal() {
2001        let input: DeriveInput = syn::parse_quote! {
2002            struct Row {
2003                id: u32,
2004                name: String,
2005            }
2006        };
2007        let ts =
2008            table::derive_table_impl(&input).expect("Table derive must succeed on minimal input");
2009        insta::assert_snapshot!(render_tokens(&ts));
2010    }
2011
2012    #[test]
2013    fn expand_panel_minimal() {
2014        let input: DeriveInput = syn::parse_quote! {
2015            struct Server {
2016                host: String,
2017                port: u16,
2018            }
2019        };
2020        let ts = panel::derive_panel_impl(&input).expect("Panel derive must succeed");
2021        insta::assert_snapshot!(render_tokens(&ts));
2022    }
2023
2024    #[test]
2025    fn expand_tree_minimal() {
2026        let input: DeriveInput = syn::parse_quote! {
2027            struct Node {
2028                #[tree(label)]
2029                label: String,
2030                #[tree(children)]
2031                children: Vec<Node>,
2032            }
2033        };
2034        let ts = tree::derive_tree_impl(&input).expect("Tree derive must succeed");
2035        insta::assert_snapshot!(render_tokens(&ts));
2036    }
2037
2038    #[test]
2039    fn expand_columns_minimal() {
2040        let input: DeriveInput = syn::parse_quote! {
2041            struct Item {
2042                title: String,
2043            }
2044        };
2045        let ts = columns::derive_columns_impl(&input).expect("Columns derive must succeed");
2046        insta::assert_snapshot!(render_tokens(&ts));
2047    }
2048
2049    #[test]
2050    fn expand_rule_minimal() {
2051        let input: DeriveInput = syn::parse_quote! {
2052            struct Section {
2053                #[rule(title)]
2054                heading: String,
2055            }
2056        };
2057        let ts = rule::derive_rule_impl(&input).expect("Rule derive must succeed");
2058        insta::assert_snapshot!(render_tokens(&ts));
2059    }
2060
2061    #[test]
2062    fn expand_inspect_minimal() {
2063        let input: DeriveInput = syn::parse_quote! {
2064            struct Status {
2065                cpu: f64,
2066                memory: f64,
2067            }
2068        };
2069        let ts = inspect::derive_inspect_impl(&input).expect("Inspect derive must succeed");
2070        insta::assert_snapshot!(render_tokens(&ts));
2071    }
2072
2073    #[test]
2074    fn expand_renderable_minimal() {
2075        let input: DeriveInput = syn::parse_quote! {
2076            struct Card {
2077                title: String,
2078            }
2079        };
2080        let ts =
2081            renderable::derive_renderable_impl(&input).expect("Renderable derive must succeed");
2082        insta::assert_snapshot!(render_tokens(&ts));
2083    }
2084}