@@ -48,6 +48,142 @@ fn rewrite_params(sql: &str) -> String {
4848 re. replace_all ( sql, ":param$1" ) . into_owned ( )
4949}
5050
51+ #[ cfg( test) ]
52+ mod tests {
53+ use std:: fs;
54+
55+ use qshape_core:: { CURRENT_SCHEMA_VERSION , ClustersDoc , Query } ;
56+ use tempfile:: TempDir ;
57+
58+ use super :: * ;
59+
60+ #[ test]
61+ fn rewrites_params_to_named ( ) {
62+ let got =
63+ rewrite_params ( "SELECT id FROM users WHERE id = $1 AND tenant_id = $2 AND id = $1" ) ;
64+ assert_eq ! (
65+ got,
66+ "SELECT id FROM users WHERE id = :param1 AND tenant_id = :param2 AND id = :param1"
67+ ) ;
68+ }
69+
70+ #[ test]
71+ fn rewrite_no_params_passthrough ( ) {
72+ let sql = "SELECT 1" ;
73+ assert_eq ! ( rewrite_params( sql) , sql) ;
74+ }
75+
76+ #[ test]
77+ fn rewrite_double_digit_params ( ) {
78+ let got = rewrite_params ( "SELECT $10 + $2" ) ;
79+ assert_eq ! ( got, "SELECT :param10 + :param2" ) ;
80+ }
81+
82+ #[ test]
83+ fn slug_strips_sha1_prefix_and_truncates ( ) {
84+ assert_eq ! ( stub_slug( 1 , "sha1:abc12345def" ) , "q01-abc12345" ) ;
85+ // rank zero-pads to 2 digits
86+ assert_eq ! ( stub_slug( 7 , "sha1:0123456789ab" ) , "q07-01234567" ) ;
87+ }
88+
89+ #[ test]
90+ fn slug_handles_short_fingerprint ( ) {
91+ // pathological — fingerprint shorter than 8 hex chars
92+ assert_eq ! ( stub_slug( 3 , "sha1:abc" ) , "q03-abc" ) ;
93+ }
94+
95+ #[ test]
96+ fn slug_handles_missing_prefix ( ) {
97+ // fingerprint without sha1: prefix (defensive)
98+ assert_eq ! ( stub_slug( 2 , "fedcba9876543210" ) , "q02-fedcba98" ) ;
99+ }
100+
101+ #[ test]
102+ fn run_writes_expected_files ( ) {
103+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
104+ let in_path = tmp. path ( ) . join ( "clusters.json" ) ;
105+ let out_dir = tmp. path ( ) . join ( "stubs" ) ;
106+
107+ let doc = ClustersDoc {
108+ schema_version : CURRENT_SCHEMA_VERSION . to_string ( ) ,
109+ clusters : vec ! [
110+ Cluster {
111+ fingerprint: "sha1:aaaaaaaa11111111" . to_string( ) ,
112+ canonical: "SELECT id FROM users WHERE id = $1" . to_string( ) ,
113+ members: vec![ Query :: default ( ) ] ,
114+ total_calls: 100 ,
115+ ..Cluster :: default ( )
116+ } ,
117+ Cluster {
118+ fingerprint: "sha1:bbbbbbbb22222222" . to_string( ) ,
119+ canonical: "SELECT 1" . to_string( ) ,
120+ total_calls: 5 ,
121+ ..Cluster :: default ( )
122+ } ,
123+ // skipped: empty fingerprint
124+ Cluster {
125+ fingerprint: String :: new( ) ,
126+ canonical: "junk" . to_string( ) ,
127+ total_calls: 999 ,
128+ ..Cluster :: default ( )
129+ } ,
130+ ] ,
131+ } ;
132+ fs:: write ( & in_path, serde_json:: to_vec ( & doc) . unwrap ( ) ) . unwrap ( ) ;
133+
134+ run ( Some ( in_path. to_str ( ) . unwrap ( ) ) , out_dir. to_str ( ) . unwrap ( ) , 10 , 0 ) . unwrap ( ) ;
135+
136+ let sql_dir = out_dir. join ( "sql" ) ;
137+ let entries: Vec < _ > = fs:: read_dir ( & sql_dir)
138+ . unwrap ( )
139+ . map ( |e| e. unwrap ( ) . file_name ( ) . into_string ( ) . unwrap ( ) )
140+ . collect ( ) ;
141+ assert_eq ! ( entries. len( ) , 2 , "third cluster has empty fingerprint, must be skipped" ) ;
142+
143+ let first = fs:: read_to_string ( sql_dir. join ( "q01-aaaaaaaa.sql" ) ) . unwrap ( ) ;
144+ assert ! ( first. contains( "-- name: q01-aaaaaaaa" ) ) ;
145+ assert ! ( first. contains( "-- Generated from qshape cluster sha1:aaaaaaaa11111111" ) ) ;
146+ assert ! ( first. contains( "Total calls (prod): 100 across 1 member variants" ) ) ;
147+ assert ! ( first. contains( "SELECT id FROM users WHERE id = :param1" ) ) ;
148+ assert ! ( first. ends_with( '\n' ) , "trailing newline must be present" ) ;
149+ }
150+
151+ #[ test]
152+ fn run_honours_top_and_min_calls ( ) {
153+ let tmp = TempDir :: new ( ) . unwrap ( ) ;
154+ let in_path = tmp. path ( ) . join ( "clusters.json" ) ;
155+ let out_dir = tmp. path ( ) . join ( "stubs" ) ;
156+
157+ let mk = |fp : & str , calls : i64 | Cluster {
158+ fingerprint : fp. to_string ( ) ,
159+ canonical : "SELECT 1" . to_string ( ) ,
160+ total_calls : calls,
161+ ..Cluster :: default ( )
162+ } ;
163+ let doc = ClustersDoc {
164+ schema_version : CURRENT_SCHEMA_VERSION . to_string ( ) ,
165+ clusters : vec ! [
166+ mk( "sha1:aa11" , 100 ) ,
167+ mk( "sha1:bb22" , 50 ) ,
168+ mk( "sha1:cc33" , 10 ) ,
169+ mk( "sha1:dd44" , 5 ) ,
170+ ] ,
171+ } ;
172+ fs:: write ( & in_path, serde_json:: to_vec ( & doc) . unwrap ( ) ) . unwrap ( ) ;
173+
174+ // top=2 caps emission
175+ run ( Some ( in_path. to_str ( ) . unwrap ( ) ) , out_dir. to_str ( ) . unwrap ( ) , 2 , 0 ) . unwrap ( ) ;
176+ let n = fs:: read_dir ( out_dir. join ( "sql" ) ) . unwrap ( ) . count ( ) ;
177+ assert_eq ! ( n, 2 ) ;
178+
179+ // min_calls=20 filters
180+ let out2 = tmp. path ( ) . join ( "stubs2" ) ;
181+ run ( Some ( in_path. to_str ( ) . unwrap ( ) ) , out2. to_str ( ) . unwrap ( ) , 10 , 20 ) . unwrap ( ) ;
182+ let n2 = fs:: read_dir ( out2. join ( "sql" ) ) . unwrap ( ) . count ( ) ;
183+ assert_eq ! ( n2, 2 , "only sha1:aa11 (100) and sha1:bb22 (50) survive min_calls=20" ) ;
184+ }
185+ }
186+
51187fn write_sql_stub ( path : & Path , slug : & str , c : & Cluster , sql : & str ) -> Result < ( ) > {
52188 let trailing = if sql. ends_with ( '\n' ) { "" } else { "\n " } ;
53189 let content = format ! (
0 commit comments