Skip to main content

max / makenotwork

15.3 KB · 490 lines History Blame Raw
1 //! License preset templates for per-item license text.
2 //!
3 //! Creators choose from common presets or write custom terms. Template text
4 //! uses `{year}` and `{owner}` placeholders, substituted at render time.
5
6 use std::fmt;
7 use std::str::FromStr;
8
9 /// Available license presets.
10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 pub enum LicensePreset {
12 PersonalUse,
13 RoyaltyFree,
14 Mit,
15 Apache2,
16 CcBy4,
17 CcByNc4,
18 Cc0,
19 Custom,
20 }
21
22 impl fmt::Display for LicensePreset {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 f.write_str(self.as_str())
25 }
26 }
27
28 impl FromStr for LicensePreset {
29 type Err = String;
30
31 fn from_str(s: &str) -> Result<Self, Self::Err> {
32 match s {
33 "personal_use" => Ok(Self::PersonalUse),
34 "royalty_free" => Ok(Self::RoyaltyFree),
35 "mit" => Ok(Self::Mit),
36 "apache2" => Ok(Self::Apache2),
37 "cc_by_4" => Ok(Self::CcBy4),
38 "cc_by_nc_4" => Ok(Self::CcByNc4),
39 "cc0" => Ok(Self::Cc0),
40 "custom" => Ok(Self::Custom),
41 other => Err(format!("invalid LicensePreset: {other}")),
42 }
43 }
44 }
45
46 impl LicensePreset {
47 /// Database/form key string.
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::PersonalUse => "personal_use",
51 Self::RoyaltyFree => "royalty_free",
52 Self::Mit => "mit",
53 Self::Apache2 => "apache2",
54 Self::CcBy4 => "cc_by_4",
55 Self::CcByNc4 => "cc_by_nc_4",
56 Self::Cc0 => "cc0",
57 Self::Custom => "custom",
58 }
59 }
60
61 /// Human-readable label for dropdown display.
62 pub fn label(&self) -> &'static str {
63 match self {
64 Self::PersonalUse => "Personal Use Only",
65 Self::RoyaltyFree => "Royalty-Free Commercial",
66 Self::Mit => "MIT License",
67 Self::Apache2 => "Apache License 2.0",
68 Self::CcBy4 => "CC BY 4.0",
69 Self::CcByNc4 => "CC BY-NC 4.0",
70 Self::Cc0 => "Public Domain (CC0)",
71 Self::Custom => "Custom",
72 }
73 }
74 }
75
76 /// All presets in display order.
77 pub const ALL_PRESETS: &[LicensePreset] = &[
78 LicensePreset::PersonalUse,
79 LicensePreset::RoyaltyFree,
80 LicensePreset::Mit,
81 LicensePreset::Apache2,
82 LicensePreset::CcBy4,
83 LicensePreset::CcByNc4,
84 LicensePreset::Cc0,
85 LicensePreset::Custom,
86 ];
87
88 /// (value, label) pairs for template rendering.
89 pub fn preset_options() -> Vec<(&'static str, &'static str)> {
90 ALL_PRESETS.iter().map(|p| (p.as_str(), p.label())).collect()
91 }
92
93 // ── Template text constants ──
94
95 const PERSONAL_USE_TEXT: &str = "\
96 Personal Use License
97
98 Copyright (c) {year} {owner}. All rights reserved.
99
100 This product is licensed for personal, non-commercial use only. You may \
101 not redistribute, resell, or sublicense this product or any derivative \
102 works. Commercial use requires a separate license from the copyright \
103 holder.";
104
105 const ROYALTY_FREE_TEXT: &str = "\
106 Royalty-Free Commercial License
107
108 Copyright (c) {year} {owner}. All rights reserved.
109
110 You are granted a perpetual, non-exclusive, worldwide license to use this \
111 product in personal and commercial projects. You may not redistribute, \
112 resell, or sublicense the product itself (in whole or in part) as a \
113 standalone product. Crediting the original author is appreciated but \
114 not required.";
115
116 const MIT_TEXT: &str = "\
117 MIT License
118
119 Copyright (c) {year} {owner}
120
121 Permission is hereby granted, free of charge, to any person obtaining a copy \
122 of this software and associated documentation files (the \"Software\"), to deal \
123 in the Software without restriction, including without limitation the rights \
124 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell \
125 copies of the Software, and to permit persons to whom the Software is \
126 furnished to do so, subject to the following conditions:
127
128 The above copyright notice and this permission notice shall be included in all \
129 copies or substantial portions of the Software.
130
131 THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR \
132 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, \
133 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE \
134 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER \
135 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, \
136 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE \
137 SOFTWARE.";
138
139 const APACHE2_TEXT: &str = "\
140 Apache License
141 Version 2.0, January 2004
142 http://www.apache.org/licenses/
143
144 Copyright (c) {year} {owner}
145
146 Licensed under the Apache License, Version 2.0 (the \"License\"); you may not \
147 use this file except in compliance with the License. You may obtain a copy of \
148 the License at
149
150 http://www.apache.org/licenses/LICENSE-2.0
151
152 Unless required by applicable law or agreed to in writing, software distributed \
153 under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR \
154 CONDITIONS OF ANY KIND, either express or implied. See the License for the \
155 specific language governing permissions and limitations under the License.";
156
157 const CC_BY_4_TEXT: &str = "\
158 Creative Commons Attribution 4.0 International (CC BY 4.0)
159
160 Copyright (c) {year} {owner}
161
162 You are free to:
163 - Share: copy and redistribute the material in any medium or format
164 - Adapt: remix, transform, and build upon the material for any purpose, \
165 even commercially
166
167 Under the following terms:
168 - Attribution: You must give appropriate credit, provide a link to the \
169 license, and indicate if changes were made.
170
171 Full license text: https://creativecommons.org/licenses/by/4.0/legalcode";
172
173 const CC_BY_NC_4_TEXT: &str = "\
174 Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
175
176 Copyright (c) {year} {owner}
177
178 You are free to:
179 - Share: copy and redistribute the material in any medium or format
180 - Adapt: remix, transform, and build upon the material
181
182 Under the following terms:
183 - Attribution: You must give appropriate credit, provide a link to the \
184 license, and indicate if changes were made.
185 - NonCommercial: You may not use the material for commercial purposes.
186
187 Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode";
188
189 const CC0_TEXT: &str = "\
190 CC0 1.0 Universal (Public Domain Dedication)
191
192 {owner} has dedicated this work to the public domain by waiving all rights \
193 under copyright law, including all related and neighboring rights, to the \
194 extent allowed by law.
195
196 You can copy, modify, distribute, and perform the work, even for commercial \
197 purposes, all without asking permission.
198
199 Full legal text: https://creativecommons.org/publicdomain/zero/1.0/legalcode";
200
201 /// Render the full license text for a preset, substituting `{year}` and `{owner}`.
202 ///
203 /// For `Custom`, the caller must supply the text via `custom_text`. If
204 /// `custom_text` is `None` for a Custom preset, returns an empty string.
205 pub fn render_license_text(
206 preset: LicensePreset,
207 owner: &str,
208 year: i32,
209 custom_text: Option<&str>,
210 ) -> String {
211 if preset == LicensePreset::Custom {
212 return custom_text.unwrap_or("").to_string();
213 }
214
215 let template = match preset {
216 LicensePreset::PersonalUse => PERSONAL_USE_TEXT,
217 LicensePreset::RoyaltyFree => ROYALTY_FREE_TEXT,
218 LicensePreset::Mit => MIT_TEXT,
219 LicensePreset::Apache2 => APACHE2_TEXT,
220 LicensePreset::CcBy4 => CC_BY_4_TEXT,
221 LicensePreset::CcByNc4 => CC_BY_NC_4_TEXT,
222 LicensePreset::Cc0 => CC0_TEXT,
223 LicensePreset::Custom => unreachable!(),
224 };
225
226 template
227 .replace("{year}", &year.to_string())
228 .replace("{owner}", owner)
229 }
230
231 #[cfg(test)]
232 mod tests {
233 use super::*;
234
235 #[test]
236 fn all_presets_round_trip() {
237 for preset in ALL_PRESETS {
238 let s = preset.as_str();
239 let parsed: LicensePreset = s.parse().unwrap();
240 assert_eq!(*preset, parsed);
241 }
242 }
243
244 #[test]
245 fn preset_labels_non_empty() {
246 for preset in ALL_PRESETS {
247 assert!(!preset.label().is_empty());
248 }
249 }
250
251 #[test]
252 fn render_substitutes_placeholders() {
253 let text = render_license_text(LicensePreset::Mit, "Alice", 2026, None);
254 assert!(text.contains("2026"));
255 assert!(text.contains("Alice"));
256 assert!(!text.contains("{year}"));
257 assert!(!text.contains("{owner}"));
258 }
259
260 #[test]
261 fn render_all_presets_contain_owner() {
262 for preset in ALL_PRESETS {
263 if *preset == LicensePreset::Custom {
264 continue;
265 }
266 let text = render_license_text(*preset, "TestOwner", 2026, None);
267 assert!(
268 text.contains("TestOwner"),
269 "{:?} should contain owner name",
270 preset
271 );
272 }
273 }
274
275 #[test]
276 fn render_custom_returns_custom_text() {
277 let text = render_license_text(
278 LicensePreset::Custom,
279 "Owner",
280 2026,
281 Some("My custom license terms."),
282 );
283 assert_eq!(text, "My custom license terms.");
284 }
285
286 #[test]
287 fn render_custom_without_text_returns_empty() {
288 let text = render_license_text(LicensePreset::Custom, "Owner", 2026, None);
289 assert_eq!(text, "");
290 }
291
292 #[test]
293 fn preset_options_has_all() {
294 let opts = preset_options();
295 assert_eq!(opts.len(), ALL_PRESETS.len());
296 }
297
298 #[test]
299 fn invalid_preset_parse_fails() {
300 assert!("nonexistent".parse::<LicensePreset>().is_err());
301 }
302
303 // ── All template variants render without leftover placeholders ──
304
305 #[test]
306 fn render_all_presets_no_leftover_placeholders() {
307 for preset in ALL_PRESETS {
308 if *preset == LicensePreset::Custom {
309 continue;
310 }
311 let text = render_license_text(*preset, "SomeOwner", 2025, None);
312 assert!(
313 !text.contains("{year}"),
314 "{:?} still contains {{year}}",
315 preset
316 );
317 assert!(
318 !text.contains("{owner}"),
319 "{:?} still contains {{owner}}",
320 preset
321 );
322 }
323 }
324
325 #[test]
326 fn render_all_presets_contain_year() {
327 for preset in ALL_PRESETS {
328 if *preset == LicensePreset::Custom || *preset == LicensePreset::Cc0 {
329 continue; // CC0 template has no {year} placeholder
330 }
331 let text = render_license_text(*preset, "Owner", 2026, None);
332 assert!(
333 text.contains("2026"),
334 "{:?} should contain the year",
335 preset
336 );
337 }
338 }
339
340 #[test]
341 fn render_personal_use_contains_non_commercial() {
342 let text = render_license_text(LicensePreset::PersonalUse, "Owner", 2026, None);
343 assert!(text.contains("non-commercial"));
344 }
345
346 #[test]
347 fn render_royalty_free_contains_perpetual() {
348 let text = render_license_text(LicensePreset::RoyaltyFree, "Owner", 2026, None);
349 assert!(text.contains("perpetual"));
350 }
351
352 #[test]
353 fn render_mit_contains_permission_notice() {
354 let text = render_license_text(LicensePreset::Mit, "Owner", 2026, None);
355 assert!(text.contains("Permission is hereby granted"));
356 assert!(text.contains("AS IS"));
357 }
358
359 #[test]
360 fn render_apache2_contains_license_url() {
361 let text = render_license_text(LicensePreset::Apache2, "Owner", 2026, None);
362 assert!(text.contains("http://www.apache.org/licenses/LICENSE-2.0"));
363 }
364
365 #[test]
366 fn render_cc_by_4_contains_attribution() {
367 let text = render_license_text(LicensePreset::CcBy4, "Owner", 2026, None);
368 assert!(text.contains("Attribution"));
369 assert!(text.contains("creativecommons.org"));
370 }
371
372 #[test]
373 fn render_cc_by_nc_4_contains_noncommercial() {
374 let text = render_license_text(LicensePreset::CcByNc4, "Owner", 2026, None);
375 assert!(text.contains("NonCommercial"));
376 assert!(text.contains("creativecommons.org"));
377 }
378
379 #[test]
380 fn render_cc0_contains_public_domain() {
381 let text = render_license_text(LicensePreset::Cc0, "Owner", 2026, None);
382 assert!(text.contains("public domain"));
383 }
384
385 // ── Special characters in variable values ──
386
387 #[test]
388 fn render_owner_with_special_characters() {
389 let text = render_license_text(LicensePreset::Mit, "O'Brien & Co. <LLC>", 2026, None);
390 assert!(text.contains("O'Brien & Co. <LLC>"));
391 }
392
393 #[test]
394 fn render_owner_with_unicode() {
395 let text = render_license_text(LicensePreset::Mit, "Müller GmbH", 2026, None);
396 assert!(text.contains("Müller GmbH"));
397 }
398
399 #[test]
400 fn render_owner_with_curly_braces() {
401 // Ensure literal braces in owner name don't break substitution
402 let text = render_license_text(LicensePreset::PersonalUse, "{braces}", 2026, None);
403 assert!(text.contains("{braces}"));
404 assert!(!text.contains("{year}"));
405 }
406
407 #[test]
408 fn render_empty_owner() {
409 let text = render_license_text(LicensePreset::Mit, "", 2026, None);
410 assert!(text.contains("Copyright (c) 2026 "));
411 assert!(!text.contains("{owner}"));
412 }
413
414 // ── Custom template edge cases ──
415
416 #[test]
417 fn render_custom_ignores_owner_and_year() {
418 let text = render_license_text(
419 LicensePreset::Custom,
420 "Ignored Owner",
421 9999,
422 Some("No substitution happens for {year} or {owner}."),
423 );
424 // Custom text is returned as-is, no substitution
425 assert!(text.contains("{year}"));
426 assert!(text.contains("{owner}"));
427 }
428
429 #[test]
430 fn render_custom_with_empty_string() {
431 let text = render_license_text(LicensePreset::Custom, "Owner", 2026, Some(""));
432 assert_eq!(text, "");
433 }
434
435 #[test]
436 fn render_custom_with_multiline_text() {
437 let custom = "Line 1\nLine 2\n\nLine 4";
438 let text = render_license_text(LicensePreset::Custom, "Owner", 2026, Some(custom));
439 assert_eq!(text, custom);
440 }
441
442 // ── Display / FromStr edge cases ──
443
444 #[test]
445 fn display_matches_as_str() {
446 for preset in ALL_PRESETS {
447 assert_eq!(format!("{preset}"), preset.as_str());
448 }
449 }
450
451 #[test]
452 fn from_str_case_sensitive() {
453 // Uppercase should fail
454 assert!("MIT".parse::<LicensePreset>().is_err());
455 assert!("Personal_Use".parse::<LicensePreset>().is_err());
456 }
457
458 #[test]
459 fn from_str_empty_string_fails() {
460 assert!("".parse::<LicensePreset>().is_err());
461 }
462
463 #[test]
464 fn preset_options_values_match_as_str() {
465 let opts = preset_options();
466 for (i, preset) in ALL_PRESETS.iter().enumerate() {
467 assert_eq!(opts[i].0, preset.as_str());
468 assert_eq!(opts[i].1, preset.label());
469 }
470 }
471
472 #[test]
473 fn all_presets_have_unique_keys() {
474 let keys: Vec<&str> = ALL_PRESETS.iter().map(|p| p.as_str()).collect();
475 let mut deduped = keys.clone();
476 deduped.sort();
477 deduped.dedup();
478 assert_eq!(keys.len(), deduped.len());
479 }
480
481 #[test]
482 fn all_presets_have_unique_labels() {
483 let labels: Vec<&str> = ALL_PRESETS.iter().map(|p| p.label()).collect();
484 let mut deduped = labels.clone();
485 deduped.sort();
486 deduped.dedup();
487 assert_eq!(labels.len(), deduped.len());
488 }
489 }
490