Skip to main content

max / makenotwork

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