Skip to main content

max / makenotwork

15.7 KB · 496 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 // Custom returned above; this arm exists only to keep the match
227 // exhaustive. Empty string matches the documented Custom-with-no-text
228 // behaviour, so it stays correct if the early return is ever dropped.
229 // It becomes real code the day Custom gains a stored template.
230 LicensePreset::Custom => "",
231 };
232
233 template
234 .replace("{year}", &year.to_string())
235 .replace("{owner}", owner)
236 }
237
238 #[cfg(test)]
239 mod tests {
240 use super::*;
241
242 #[test]
243 fn all_presets_round_trip() {
244 for preset in ALL_PRESETS {
245 let s = preset.as_str();
246 let parsed: LicensePreset = s.parse().unwrap();
247 assert_eq!(*preset, parsed);
248 }
249 }
250
251 #[test]
252 fn preset_labels_non_empty() {
253 for preset in ALL_PRESETS {
254 assert!(!preset.label().is_empty());
255 }
256 }
257
258 #[test]
259 fn render_substitutes_placeholders() {
260 let text = render_license_text(LicensePreset::Mit, "Alice", 2026, None);
261 assert!(text.contains("2026"));
262 assert!(text.contains("Alice"));
263 assert!(!text.contains("{year}"));
264 assert!(!text.contains("{owner}"));
265 }
266
267 #[test]
268 fn render_all_presets_contain_owner() {
269 for preset in ALL_PRESETS {
270 if *preset == LicensePreset::Custom {
271 continue;
272 }
273 let text = render_license_text(*preset, "TestOwner", 2026, None);
274 assert!(
275 text.contains("TestOwner"),
276 "{preset:?} should contain owner name"
277 );
278 }
279 }
280
281 #[test]
282 fn render_custom_returns_custom_text() {
283 let text = render_license_text(
284 LicensePreset::Custom,
285 "Owner",
286 2026,
287 Some("My custom license terms."),
288 );
289 assert_eq!(text, "My custom license terms.");
290 }
291
292 #[test]
293 fn render_custom_without_text_returns_empty() {
294 let text = render_license_text(LicensePreset::Custom, "Owner", 2026, None);
295 assert_eq!(text, "");
296 }
297
298 #[test]
299 fn preset_options_has_all() {
300 let opts = preset_options();
301 assert_eq!(opts.len(), ALL_PRESETS.len());
302 }
303
304 #[test]
305 fn invalid_preset_parse_fails() {
306 assert!("nonexistent".parse::<LicensePreset>().is_err());
307 }
308
309 // ── All template variants render without leftover placeholders ──
310
311 #[test]
312 fn render_all_presets_no_leftover_placeholders() {
313 for preset in ALL_PRESETS {
314 if *preset == LicensePreset::Custom {
315 continue;
316 }
317 let text = render_license_text(*preset, "SomeOwner", 2025, None);
318 assert!(
319 !text.contains("{year}"),
320 "{preset:?} still contains {{year}}"
321 );
322 assert!(
323 !text.contains("{owner}"),
324 "{preset:?} still contains {{owner}}"
325 );
326 }
327 }
328
329 #[test]
330 fn render_all_presets_contain_year() {
331 for preset in ALL_PRESETS {
332 if *preset == LicensePreset::Custom || *preset == LicensePreset::Cc0 {
333 continue; // CC0 template has no {year} placeholder
334 }
335 let text = render_license_text(*preset, "Owner", 2026, None);
336 assert!(text.contains("2026"), "{preset:?} should contain the year");
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
475 .iter()
476 .map(super::LicensePreset::as_str)
477 .collect();
478 let mut deduped = keys.clone();
479 deduped.sort_unstable();
480 deduped.dedup();
481 assert_eq!(keys.len(), deduped.len());
482 }
483
484 #[test]
485 fn all_presets_have_unique_labels() {
486 let labels: Vec<&str> = ALL_PRESETS
487 .iter()
488 .map(super::LicensePreset::label)
489 .collect();
490 let mut deduped = labels.clone();
491 deduped.sort_unstable();
492 deduped.dedup();
493 assert_eq!(labels.len(), deduped.len());
494 }
495 }
496