• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 16536075581

26 Jul 2025 04:27AM UTC coverage: 56.347% (+8.5%) from 47.804%
16536075581

push

github

HebaruSan
Merge #4408 Add tests for CmdLine

4557 of 8422 branches covered (54.11%)

Branch coverage included in aggregate %.

148 of 273 new or added lines in 28 files covered. (54.21%)

18 existing lines in 5 files now uncovered.

9719 of 16914 relevant lines covered (57.46%)

1.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.65
/Core/Types/CkanModule.cs
1
using System;
2
using System.ComponentModel;
3
using System.Collections.Generic;
4
using System.IO;
5
using System.Linq;
6
using System.Runtime.Serialization;
7
using System.Text;
8
using System.Text.RegularExpressions;
9
using System.Diagnostics.CodeAnalysis;
10

11
using Autofac;
12
using log4net;
13
using Newtonsoft.Json;
14

15
using CKAN.Versioning;
16
using CKAN.Games;
17
using CKAN.Extensions;
18

19
namespace CKAN
20
{
21
    /// <summary>
22
    ///     Describes a CKAN module (ie, what's in the CKAN.schema file).
23
    /// </summary>
24

25
    // Base class for both modules (installed via the CKAN) and bundled
26
    // modules (which are more lightweight)
27
    [JsonObject(MemberSerialization.OptIn)]
28
    public class CkanModule : IEquatable<CkanModule>
29
    {
30

31
        #region Fields
32

33
        private static readonly ILog log = LogManager.GetLogger(typeof (CkanModule));
2✔
34

35
        // identifier, license, and version are always required, so we know
36
        // what we've got.
37

38
        [JsonProperty("abstract", Order = 5)]
39
        public string @abstract;
40

41
        [JsonProperty("description", Order = 6, NullValueHandling = NullValueHandling.Ignore)]
42
        public string? description;
43

44
        // Package type: in spec v1.6 can be either "package" or "metapackage"
45
        // In spec v1.28, "dlc"
46
        [JsonProperty("kind", Order = 31,
47
                      NullValueHandling = NullValueHandling.Ignore,
48
                      DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
49
        [JsonConverter(typeof(JsonReleaseStatusConverter))]
50
        [DefaultValue(ModuleKind.package)]
51
        public ModuleKind kind = ModuleKind.package;
2✔
52

53
        [JsonProperty("author", Order = 7, NullValueHandling = NullValueHandling.Ignore)]
54
        [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
55
        public List<string> author;
56

57
        [JsonProperty("comment", Order = 2, NullValueHandling = NullValueHandling.Ignore)]
58
        public string? comment;
59

60
        [JsonProperty("conflicts", Order = 23, NullValueHandling = NullValueHandling.Ignore)]
61
        [JsonConverter(typeof(JsonRelationshipConverter))]
62
        public List<RelationshipDescriptor>? conflicts;
63

64
        [JsonProperty("depends", Order = 19, NullValueHandling = NullValueHandling.Ignore)]
65
        [JsonConverter(typeof(JsonRelationshipConverter))]
66
        public List<RelationshipDescriptor>? depends;
67

68
        [JsonProperty("replaced_by", NullValueHandling = NullValueHandling.Ignore)]
69
        public ModuleRelationshipDescriptor? replaced_by;
70

71
        [JsonProperty("download", Order = 25, NullValueHandling = NullValueHandling.Ignore)]
72
        [JsonConverter(typeof(JsonSingleOrArrayConverter<Uri>))]
73
        public List<Uri>? download;
74

75
        [JsonProperty("download_size", Order = 26, DefaultValueHandling = DefaultValueHandling.Ignore)]
76
        [DefaultValue(0)]
77
        public long download_size;
78

79
        [JsonProperty("download_hash", Order = 27, NullValueHandling = NullValueHandling.Ignore)]
80
        public DownloadHashesDescriptor? download_hash;
81

82
        [JsonProperty("download_content_type", Order = 28, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
83
        [DefaultValue("application/zip")]
84
        public string? download_content_type;
85

86
        [JsonProperty("install_size", Order = 29, DefaultValueHandling = DefaultValueHandling.Ignore)]
87
        [DefaultValue(0)]
88
        public long install_size;
89

90
        [JsonProperty("identifier", Order = 3, Required = Required.Always)]
91
        public string identifier;
92

93
        [JsonProperty("ksp_version", Order = 9, NullValueHandling = NullValueHandling.Ignore)]
94
        public GameVersion? ksp_version;
95

96
        [JsonProperty("ksp_version_max", Order = 11, NullValueHandling = NullValueHandling.Ignore)]
97
        public GameVersion? ksp_version_max;
98

99
        [JsonProperty("ksp_version_min", Order = 10, NullValueHandling = NullValueHandling.Ignore)]
100
        public GameVersion? ksp_version_min;
101

102
        [JsonProperty("ksp_version_strict", Order = 12, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
103
        [DefaultValue(false)]
104
        public bool? ksp_version_strict = false;
2✔
105

106
        [JsonProperty("license", Order = 13)]
107
        [JsonConverter(typeof(JsonSingleOrArrayConverter<License>))]
108
        public List<License> license;
109

110
        [JsonProperty("name", Order = 4)]
111
        public string name;
112

113
        [JsonProperty("provides", Order = 18, NullValueHandling = NullValueHandling.Ignore)]
114
        public List<string>? provides;
115

116
        [JsonProperty("recommends", Order = 20, NullValueHandling = NullValueHandling.Ignore)]
117
        [JsonConverter(typeof(JsonRelationshipConverter))]
118
        public List<RelationshipDescriptor>? recommends;
119

120
        [JsonProperty("release_status", Order = 14,
121
                      NullValueHandling = NullValueHandling.Ignore,
122
                      DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
123
        [DefaultValue(ReleaseStatus.stable)]
124
        public ReleaseStatus? release_status = ReleaseStatus.stable;
2✔
125

126
        [JsonProperty("resources", Order = 15, NullValueHandling = NullValueHandling.Ignore)]
127
        public ResourcesDescriptor? resources;
128

129
        [JsonProperty("suggests", Order = 21, NullValueHandling = NullValueHandling.Ignore)]
130
        [JsonConverter(typeof(JsonRelationshipConverter))]
131
        public List<RelationshipDescriptor>? suggests;
132

133
        [JsonProperty("version", Order = 8, Required = Required.Always)]
134
        public ModuleVersion version;
135

136
        [JsonProperty("supports", Order = 22, NullValueHandling = NullValueHandling.Ignore)]
137
        [JsonConverter(typeof(JsonRelationshipConverter))]
138
        public List<RelationshipDescriptor>? supports;
139

140
        [JsonProperty("install", Order = 24, NullValueHandling = NullValueHandling.Ignore)]
141
        public ModuleInstallDescriptor[]? install;
142

143
        [JsonProperty("localizations", Order = 17, NullValueHandling = NullValueHandling.Ignore)]
144
        public string[]? localizations;
145

146
        // Used to see if we're compatible with a given game/KSP version or not.
147
        private readonly IGameComparator _comparator;
148

149
        [JsonIgnore]
150
        [JsonProperty("specVersion", Required = Required.Default)]
151
        private ModuleVersion specVersion;
152
        // We integrated the Module and CkanModule into one class
153
        // Since spec_version was only required for CkanModule before
154
        // this change, we now need to make sure the user is converted
155
        // and has the spec_version's in his installed_modules section
156
        // We should return this to a simple Required.Always field some time in the future
157
        // ~ Postremus, 03.09.2015
158
        [JsonProperty(nameof(spec_version), Order = 1)]
159
        public ModuleVersion spec_version
160
        {
161
            get
162
            {
2✔
163
                if (specVersion == null)
2✔
164
                {
2✔
165
                    specVersion = new ModuleVersion("1");
2✔
166
                }
2✔
167

168
                return specVersion;
2✔
169
            }
2✔
170
            #pragma warning disable IDE0027
171
            [MemberNotNull(nameof(specVersion))]
172
            set
173
            {
2✔
174
                specVersion = value ?? new ModuleVersion("1");
2✔
175
            }
2✔
176
            #pragma warning restore IDE0027
177
        }
178

179
        [JsonProperty("tags", Order = 16, NullValueHandling = NullValueHandling.Ignore)]
180
        public HashSet<string>? Tags;
181

182
        [JsonProperty("release_date", Order = 30, NullValueHandling = NullValueHandling.Ignore)]
183
        public DateTime? release_date;
184

185
        // A list of eveything this mod provides.
186
        public List<string> ProvidesList
187
        {
188
            // TODO: Consider caching this, but not in a way that the serialiser will try and
189
            // serialise it.
190
            get
191
            {
2✔
192
                var provides = new List<string> { identifier };
2✔
193

194
                if (this.provides != null)
2✔
195
                {
2✔
196
                    provides.AddRange(this.provides);
2✔
197
                }
2✔
198

199
                return provides;
2✔
200
            }
2✔
201
        }
202

203
        // These are used to simplify the search by dropping special chars.
204
        [JsonIgnore]
205
        public string SearchableName;
206
        [JsonIgnore]
207
        public string SearchableIdentifier;
208
        [JsonIgnore]
209
        public string SearchableAbstract;
210
        [JsonIgnore]
211
        public string SearchableDescription;
212
        [JsonIgnore]
213
        public List<string> SearchableAuthors;
214
        // This regex finds all those special chars.
215
        [JsonIgnore]
216
        public static readonly Regex nonAlphaNums = new Regex("[^a-zA-Z0-9]", RegexOptions.Compiled);
2✔
217

218
        #endregion
219

220
        #region Constructors
221

222
        /// <summary>
223
        /// Initialize a CkanModule
224
        /// </summary>
225
        /// <param name="spec_version">The version of the spec obeyed by this module</param>
226
        /// <param name="identifier">This module's machine-readable identifier</param>
227
        /// <param name="name">This module's user-visible display name</param>
228
        /// <param name="abstract">Short description of this module</param>
229
        /// <param name="description">Long description of this module</param>
230
        /// <param name="author">Authors of this module</param>
231
        /// <param name="license">Licenses of this module</param>
232
        /// <param name="version">Version number of this release</param>
233
        /// <param name="download">Where to download this module</param>
234
        /// <param name="kind">package, metapackage, or dlc</param>
235
        /// <param name="comparator">Object used for checking compatibility of this module</param>
236
        [JsonConstructor]
237
        public CkanModule(
2✔
238
            ModuleVersion    spec_version,
239
            string           identifier,
240
            string           name,
241
            string           @abstract,
242
            string?          description,
243
            [JsonConverter(typeof(JsonSingleOrArrayConverter<string>))]
244
            List<string>     author,
245
            [JsonConverter(typeof(JsonSingleOrArrayConverter<License>))]
246
            List<License>    license,
247
            ModuleVersion    version,
248
            [JsonConverter(typeof(JsonSingleOrArrayConverter<Uri>))]
249
            List<Uri>?       download,
250
            ModuleKind       kind = ModuleKind.package,
251
            IGameComparator? comparator = null)
252
        {
2✔
253
            this.spec_version = spec_version;
2✔
254
            this.identifier   = identifier;
2✔
255
            this.name         = name;
2✔
256
            this.@abstract    = @abstract;
2✔
257
            this.description  = description;
2✔
258
            this.author       = author;
2✔
259
            this.license      = license;
2✔
260
            this.version      = version;
2✔
261
            this.download     = download;
2✔
262
            this.kind         = kind;
2✔
263
            _comparator  = comparator ?? ServiceLocator.Container.Resolve<IGameComparator>();
2✔
264
            CheckHealth();
2✔
265
            CalculateSearchables();
2✔
266
        }
2✔
267

268
        /// <summary>
269
        /// Inflates a CKAN object from a JSON string.
270
        /// </summary>
271
        public CkanModule(string json, IGameComparator? comparator = null)
2✔
272
        {
2✔
273
            try
274
            {
2✔
275
                // Use the json string to populate our object
276
                JsonConvert.PopulateObject(json, this, new JsonSerializerSettings
2✔
277
                {
278
                    DateTimeZoneHandling = DateTimeZoneHandling.Utc,
279
                });
280
            }
2✔
281
            catch (JsonException ex)
2✔
282
            {
2✔
283
                throw new BadMetadataKraken(null,
2✔
284
                    string.Format(Properties.Resources.CkanModuleDeserialisationError, ex.Message),
285
                    ex);
286
            }
287
            _comparator = comparator ?? ServiceLocator.Container.Resolve<IGameComparator>();
2✔
288
            CheckHealth();
2✔
289
            CalculateSearchables();
2✔
290
        }
2✔
291

292
        /// <summary>
293
        /// Throw an exception if there's anything wrong with this module
294
        /// </summary>
295
        [MemberNotNull(nameof(specVersion), nameof(identifier), nameof(name),
296
                       nameof(@abstract),   nameof(author),     nameof(license),
297
                       nameof(version))]
298
        private void CheckHealth()
299
        {
2✔
300
            // Check everything in the spec is defined.
301
            if (spec_version == null)
2!
302
            {
×
303
                throw new BadMetadataKraken(null,
×
304
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
305
                                                          identifier, "spec_version"));
306
            }
307
            if (!IsSpecSupported())
2✔
308
            {
2✔
309
                throw new UnsupportedKraken(string.Format(
2✔
310
                    Properties.Resources.CkanModuleUnsupportedSpec, this, spec_version));
311
            }
312
            if (identifier == null)
2!
313
            {
×
314
                throw new BadMetadataKraken(null,
×
315
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
316
                                                          identifier, "identifier"));
317
            }
318
            if (name == null)
2!
319
            {
×
320
                throw new BadMetadataKraken(null,
×
321
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
322
                                                          identifier, "name"));
323
            }
324
            if (@abstract == null)
2!
325
            {
×
326
                throw new BadMetadataKraken(null,
×
327
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
328
                                                          identifier, "abstract"));
329
            }
330
            if (license == null)
2!
331
            {
×
332
                throw new BadMetadataKraken(null,
×
333
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
334
                                                          identifier, "license"));
335
            }
336
            if (version == null)
2!
337
            {
×
338
                throw new BadMetadataKraken(null,
×
339
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
340
                                                          identifier, "version"));
341
            }
342
            if (author == null)
2✔
343
            {
2✔
344
                if (spec_version < v1p28)
2!
345
                {
2✔
346
                    // Some very old modules in the test data lack authors
347
                    author = new List<string> { "" };
2✔
348
                }
2✔
349
                else
350
                {
×
351
                    throw new BadMetadataKraken(null,
×
352
                                                string.Format(Properties.Resources.CkanModuleMissingRequired,
353
                                                              identifier, "author"));
354
                }
355
            }
2✔
356
            if (kind == ModuleKind.package && download == null)
2✔
357
            {
2✔
358
                throw new BadMetadataKraken(null,
2✔
359
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
360
                                                          identifier, "download"));
361
            }
362
            if (release_status is not (ReleaseStatus.stable
2✔
363
                                       or ReleaseStatus.development
364
                                       or ReleaseStatus.testing))
365
            {
2✔
366
                throw new BadMetadataKraken(
2✔
367
                    null,
368
                    string.Format(Properties.Resources.ReleaseStatusInvalid,
369
                                  release_status));
370
            }
371
        }
2✔
372

373
        private static readonly ModuleVersion v1p28 = new ModuleVersion("v1.28");
2✔
374

375
        /// <summary>
376
        /// Calculate the mod properties used for searching via Regex.
377
        /// </summary>
378
        [MemberNotNull(nameof(SearchableIdentifier)),
379
         MemberNotNull(nameof(SearchableName)),
380
         MemberNotNull(nameof(SearchableAbstract)),
381
         MemberNotNull(nameof(SearchableDescription)),
382
         MemberNotNull(nameof(SearchableAuthors))]
383
        private void CalculateSearchables()
384
        {
2✔
385
            SearchableIdentifier  = identifier  == null ? string.Empty : nonAlphaNums.Replace(identifier, "");
2✔
386
            SearchableName        = name        == null ? string.Empty : nonAlphaNums.Replace(name, "");
2✔
387
            SearchableAbstract    = @abstract   == null ? string.Empty : nonAlphaNums.Replace(@abstract, "");
2✔
388
            SearchableDescription = description == null ? string.Empty : nonAlphaNums.Replace(description, "");
2✔
389
            SearchableAuthors     = author?.Select(auth => nonAlphaNums.Replace(auth, ""))
2✔
390
                                           .ToList()
391
                                          ?? new List<string> { string.Empty };
392
        }
2✔
393

394
        public string serialise()
395
            => JsonConvert.SerializeObject(this);
×
396

397
        [OnDeserialized]
398
        private void DeSerialisationFixes(StreamingContext like_i_could_care)
399
        {
2✔
400
            // Make sure our version fields are populated.
401
            // TODO: There's got to be a better way of doing this, right?
402

403
            // Now see if we've got version with version min/max.
404
            if (ksp_version != null && (ksp_version_max != null || ksp_version_min != null))
2!
405
            {
×
406
                // KSP version mixed with min/max.
407
                throw new InvalidModuleAttributesKraken(Properties.Resources.CkanModuleKspVersionMixed, this);
×
408
            }
409

410
            license   ??= new List<License> { License.UnknownLicense };
2✔
411
            @abstract ??= string.Empty;
2✔
412
            name      ??= string.Empty;
2✔
413

414
            if (kind == ModuleKind.dlc && version is not UnmanagedModuleVersion)
2✔
415
            {
2✔
416
                version = new UnmanagedModuleVersion(version.ToString());
2✔
417
            }
2✔
418

419
            CalculateSearchables();
2✔
420
        }
2✔
421

422
        /// <summary>
423
        /// Tries to parse an identifier in the format identifier=version and returns a matching CkanModule from the registry.
424
        /// Returns the latest compatible or installed module if no version has been given.
425
        /// </summary>
426
        /// <param name="registry">CKAN registry object for current game instance</param>
427
        /// <param name="mod">The identifier or identifier=version of the module</param>
428
        /// <param name="ksp_version">The current KSP version criteria to consider</param>
429
        /// <returns>A CkanModule</returns>
430
        /// <exception cref="ModuleNotFoundKraken">Thrown if no matching module could be found</exception>
431
        public static CkanModule? FromIDandVersion(IRegistryQuerier     registry,
432
                                                   string               mod,
433
                                                   GameVersionCriteria? ksp_version)
434
        {
2✔
435

436
            Match match = idAndVersionMatcher.Match(mod);
2✔
437

438
            if (match.Success)
2✔
439
            {
2✔
440
                string ident   = match.Groups["mod"].Value;
2✔
441
                string version = match.Groups["version"].Value;
2✔
442

443
                var module = registry.GetModuleByVersion(ident, version);
2✔
444

445
                if (module == null
2!
446
                        || (ksp_version != null && !module.IsCompatible(ksp_version)))
447
                {
×
448
                    throw new ModuleNotFoundKraken(ident, version,
×
449
                        string.Format(Properties.Resources.CkanModuleNotAvailable, ident, version));
450
                }
451
                return module;
2✔
452
            }
453
            return null;
2✔
454
        }
2✔
455

456
        public static readonly Regex idAndVersionMatcher =
2✔
457
            new Regex(@"^(?<mod>[^=]*)=(?<version>.*)$",
458
                      RegexOptions.Compiled);
459

460
        /// <summary> Generates a CKAN.Meta object given a filename</summary>
461
        public static CkanModule FromFile(string filename)
462
            => FromJson(File.ReadAllText(filename));
2✔
463

464
        public static void ToFile(CkanModule module, string filename)
465
        {
×
466
            ToJson(module).WriteThroughTo(filename);
×
467
        }
×
468

469
        public static string ToJson(CkanModule module)
470
        {
2✔
471
            var sw = new StringWriter(new StringBuilder());
2✔
472
            using (var writer = new JsonTextWriter(sw))
2✔
473
            {
2✔
474
                writer.Formatting  = Formatting.Indented;
2✔
475
                writer.Indentation = 4;
2✔
476
                writer.IndentChar  = ' ';
2✔
477
                new JsonSerializer().Serialize(writer, module);
2✔
478
            }
2✔
479
            return sw + Environment.NewLine;
2!
480
        }
2✔
481

482
        /// <summary>
483
        /// Generates a CkanModule object from a string.
484
        /// Also validates that all required fields are present.
485
        /// Throws a BadMetaDataKraken if any fields are missing.
486
        /// </summary>
487
        public static CkanModule FromJson(string json)
488
            => new CkanModule(json);
2✔
489

490
        #endregion
491

492
        /// <summary>
493
        /// Returns true if we conflict with the given module.
494
        /// </summary>
495
        public bool ConflictsWith(CkanModule module)
496
            // We never conflict with ourselves, since we can't be installed at
497
            // the same time as another version of ourselves.
498
            => module.identifier != identifier
2✔
499
                && (UniConflicts(this, module) || UniConflicts(module, this));
500

501
        /// <summary>
502
        /// Checks if A conflicts with B, but not if B conflicts with A.
503
        /// Used by ConflictsWith.
504
        /// </summary>
505
        internal static bool UniConflicts(CkanModule mod1, CkanModule mod2)
506
            => mod1?.conflicts?.Any(
2✔
507
                   conflict => conflict.MatchesAny(new CkanModule[] {mod2}, null, null))
2✔
508
               ?? false;
509

510
        /// <summary>
511
        /// Returns true if our mod is compatible with the KSP version specified.
512
        /// </summary>
513
        public bool IsCompatible(GameVersionCriteria version)
514
        {
2✔
515
            var compat = _comparator.Compatible(version, this);
2✔
516
            log.DebugFormat("Checking compat of {0} with game versions {1}: {2}",
2✔
517
                            this,
518
                            version.ToString(),
519
                            compat ? "Compatible": "Incompatible");
520
            return compat;
2✔
521
        }
2✔
522

523
        /// <summary>
524
        /// Returns machine readable object indicating the highest compatible
525
        /// version of KSP this module will run with.
526
        /// </summary>
527
        public GameVersion LatestCompatibleGameVersion()
528
            // Find the highest compatible KSP version
529
            => ksp_version_max ?? ksp_version
2✔
530
               // No upper limit.
531
               ?? GameVersion.Any;
532

533
        /// <summary>
534
        /// Returns machine readable object indicating the lowest compatible
535
        /// version of KSP this module will run with.
536
        /// </summary>
537
        public GameVersion EarliestCompatibleGameVersion()
538
            // Find the lowest compatible KSP version
539
            => ksp_version_min ?? ksp_version
2✔
540
               // No lower limit.
541
               ?? GameVersion.Any;
542

543
        /// <summary>
544
        /// Return the latest game version from the given list that is
545
        /// compatible with this module, without the build number.
546
        /// </summary>
547
        /// <param name="realVers">Game versions to test, sorted from earliest to latest</param>
548
        /// <returns>The latest game version if any, else null</returns>
549
        public GameVersion LatestCompatibleRealGameVersion(List<GameVersion> realVers)
550
            => LatestCompatibleRealGameVersion(new GameVersionRange(EarliestCompatibleGameVersion(),
2✔
551
                                                                    LatestCompatibleGameVersion()),
552
                                               realVers);
553

554
        private GameVersion LatestCompatibleRealGameVersion(GameVersionRange range,
555
                                                            List<GameVersion> realVers)
556
            => (realVers?.LastOrDefault(range.Contains)
2✔
557
                        ?? LatestCompatibleGameVersion());
558

559
        public bool IsMetapackage => kind == ModuleKind.metapackage;
2✔
560

561
        public bool IsDLC => kind == ModuleKind.dlc;
2✔
562

563
        protected bool Equals(CkanModule? other)
564
            => string.Equals(identifier, other?.identifier) && version.Equals(other?.version);
2!
565

566
        public override bool Equals(object? obj)
567
            => obj is not null
2✔
568
                && (ReferenceEquals(this, obj)
569
                    || (obj.GetType() == GetType() && Equals((CkanModule)obj)));
570

571
        public bool MetadataEquals(CkanModule other)
572
        {
2✔
573
            if ((install == null) != (other.install == null)
2✔
574
                    || (install != null && other.install != null
575
                        && install.Length != other.install.Length))
576
            {
2✔
577
                return false;
2✔
578
            }
579
            else if (install != null && other.install != null)
2✔
580
            {
2✔
581
                for (int i = 0; i < install.Length; i++)
4✔
582
                {
2✔
583
                    if (!install[i].Equals(other.install[i]))
2!
584
                    {
×
585
                        return false;
×
586
                    }
587
                }
2✔
588
            }
2✔
589

590
            if (install_size != other.install_size)
2!
591
            {
×
592
                return false;
×
593
            }
594
            if (download_hash?.sha256 != null && other.download_hash?.sha256 != null
2!
595
                && download_hash.sha256 != other.download_hash.sha256)
596
            {
×
597
                return false;
×
598
            }
599
            if (download_hash?.sha1 != null && other.download_hash?.sha1 != null
2!
600
                && download_hash.sha1 != other.download_hash.sha1)
601
            {
×
602
                return false;
×
603
            }
604

605
            if (!RelationshipsAreEquivalent(conflicts,  other.conflicts))
2!
606
            {
×
607
                return false;
×
608
            }
609

610
            if (!RelationshipsAreEquivalent(depends,    other.depends))
2!
611
            {
×
612
                return false;
×
613
            }
614

615
            if (!RelationshipsAreEquivalent(recommends, other.recommends))
2!
616
            {
×
617
                return false;
×
618
            }
619

620
            if (replaced_by == null ? other.replaced_by != null
2!
621
                                    : !replaced_by.Equals(other.replaced_by))
622
            {
×
623
                return false;
×
624
            }
625

626
            if (provides != other.provides)
2!
627
            {
×
628
                if (provides == null || other.provides == null)
×
629
                {
×
630
                    return false;
×
631
                }
NEW
632
                else if (!provides.Order().SequenceEqual(other.provides.Order()))
×
633
                {
×
634
                    return false;
×
635
                }
636
            }
×
637
            return true;
2✔
638
        }
2✔
639

640
        private static bool RelationshipsAreEquivalent(List<RelationshipDescriptor>? a, List<RelationshipDescriptor>? b)
641
        {
2✔
642
            if (a == b)
2✔
643
            {
2✔
644
                // If they're the same exact object they must be equivalent
645
                return true;
2✔
646
            }
647

648
            if (a == null || b == null)
2!
649
            {
×
650
                // If they're not the same exact object and either is null then must not be equivalent
651
                return false;
×
652
            }
653

654
            if (a.Count != b.Count)
2!
655
            {
×
656
                // If their counts different they must not be equivalent
657
                return false;
×
658
            }
659

660
            // Sort the lists so we can compare each relationship
661
            var aSorted = a.OrderBy(i => i.ToString()).ToList();
2✔
662
            var bSorted = b.OrderBy(i => i.ToString()).ToList();
2✔
663

664
            for (var i = 0; i < a.Count; i++)
4✔
665
            {
2✔
666
                var aRel = aSorted[i];
2✔
667
                var bRel = bSorted[i];
2✔
668

669
                if (!aRel.Equals(bRel))
2!
670
                {
×
671
                    return false;
×
672
                }
673
            }
2✔
674

675
            // If we couldn't find any differences they must be equivalent
676
            return true;
2✔
677
        }
2✔
678

679
        public override int GetHashCode()
680
            => (identifier, version).GetHashCode();
2✔
681

682
        bool IEquatable<CkanModule>.Equals(CkanModule? other)
683
            => Equals(other);
2✔
684

685
        /// <summary>
686
        /// Returns true if we support at least spec_version of the CKAN spec.
687
        /// </summary>
688
        internal static bool IsSpecSupported(ModuleVersion spec_version)
689
            => Meta.IsNetKAN || Meta.SpecVersion.IsGreaterThan(spec_version);
2!
690

691
        /// <summary>
692
        /// Returns true if we support the CKAN spec used by this module.
693
        /// </summary>
694
        [MemberNotNull(nameof(specVersion), nameof(spec_version))]
695
        private bool IsSpecSupported()
696
        {
2✔
697
            if (specVersion == null || spec_version == null)
2!
698
            {
×
699
                throw new BadMetadataKraken(null,
×
700
                                            string.Format(Properties.Resources.CkanModuleMissingRequired,
701
                                                          identifier, "specVersion"));
702
            }
703
            return IsSpecSupported(spec_version);
2✔
704
        }
2✔
705

706
        /// <summary>
707
        ///     Returns a standardised name for this module, in the form
708
        ///     "identifier-version.zip". For example, `RealSolarSystem-7.3.zip`
709
        /// </summary>
710
        public string StandardName()
711
            => StandardName(identifier, version);
2✔
712

713
        public static string StandardName(string identifier, ModuleVersion version)
714
            => $"{identifier}-{sanitizerPattern.Replace(version.ToString(), "-")}.zip";
2✔
715

716
        public override string ToString()
717
            => string.Format("{0} {1}", identifier, version);
2✔
718

719
        public string DescribeInstallStanzas(IGame game)
720
            => install == null
2!
721
                ? ModuleInstallDescriptor.DefaultInstallStanza(game, identifier)
722
                                         .DescribeMatch()
723
                : string.Join(", ", install.Select(mid => mid.DescribeMatch()));
×
724

725
        /// <summary>
726
        /// Return an archive.org URL for this download, or null if it's not there.
727
        /// The filenames look a lot like the filenames in Net.Cache, but don't be fooled!
728
        /// Here it's the first 8 characters of the SHA1 of the DOWNLOADED FILE, not the URL!
729
        /// </summary>
730
        public Uri? InternetArchiveDownload
731
            => !license.Any(l => l.Redistributable)
2!
732
                ? null
733
                : InternetArchiveURL(
734
                    Truncate(bucketExcludePattern.Replace(identifier
735
                                                              + "-"
736
                                                              + version.ToString()
737
                                                                       .Replace(' ', '_')
738
                                                                       .Replace(':', '-'),
739
                                                          ""),
740
                             100),
741
                    // Some alternate registry repositories don't set download_hash
742
                    download_hash?.sha1 ?? download_hash?.sha256);
743

744
        private static string Truncate(string s, int len)
745
            => s.Length <= len ? s
2!
746
                               : s[..len];
747

748
        private static Uri? InternetArchiveURL(string bucket, string? hash)
749
            => hash == null || string.IsNullOrEmpty(hash)
2!
750
                ? null
751
                : new Uri($"https://archive.org/download/{bucket}/{hash[..8]}-{bucket}.zip");
752

753
        // Versions can contain ALL SORTS OF WACKY THINGS! Colons, friggin newlines,
754
        // slashes, and heaven knows what else mod authors try to smoosh into them.
755
        // We'll reduce this down to "friendly" characters, replacing everything else with
756
        // dashes. This doesn't change look-ups, as we use the hash prefix for that.
757
        private static readonly Regex sanitizerPattern = new Regex("[^A-Za-z0-9_.-]",
2✔
758
                                                                   RegexOptions.Compiled);
759

760
        // InternetArchive says:
761
        // Bucket names should be valid archive identifiers;
762
        // try someting matching this regular expression:
763
        // ^[a-zA-Z0-9][a-zA-Z0-9_.-]{4,100}$
764
        // (We enforce everything except the minimum of 4 characters)
765
        private static readonly Regex bucketExcludePattern = new Regex(@"^[^a-zA-Z0-9]+|[^a-zA-Z0-9._-]",
2✔
766
                                                                       RegexOptions.Compiled);
767

768
        private const double K = 1024;
769

770
        /// <summary>
771
        /// Format a byte count into readable file size
772
        /// </summary>
773
        /// <param name="bytes">Number of bytes in a file</param>
774
        /// <returns>
775
        /// ### bytes or ### KiB or ### MiB or ### GiB or ### TiB
776
        /// </returns>
777
        public static string FmtSize(long bytes)
778
            => bytes < K       ? $"{bytes} B"
2!
779
             : bytes < K*K     ? $"{bytes /K :N1} KiB"
780
             : bytes < K*K*K   ? $"{bytes /K/K :N1} MiB"
781
             : bytes < K*K*K*K ? $"{bytes /K/K/K :N1} GiB"
782
             :                   $"{bytes /K/K/K/K :N1} TiB";
783

784
        public HashSet<CkanModule> GetDownloadsGroup(IEnumerable<CkanModule> modules)
785
            => OneDownloadGroupingPass(modules.ToHashSet(), this);
×
786

787
        public static List<HashSet<CkanModule>> GroupByDownloads(IEnumerable<CkanModule> modules)
788
        {
2✔
789
            // Each module is a vertex, each download URL is an edge
790
            // We want to group the vertices by transitive connectedness
791
            // We can go breadth first or depth first
792
            // Once we encounter a mod, we never have to look at it again
793
            var unsearched = modules.ToHashSet();
2✔
794
            var groups = new List<HashSet<CkanModule>>();
2✔
795
            while (unsearched.Count > 0)
2✔
796
            {
2✔
797
                groups.Add(OneDownloadGroupingPass(unsearched, unsearched.First()));
2✔
798
            }
2✔
799
            return groups;
2✔
800
        }
2✔
801

802
        private static HashSet<CkanModule> OneDownloadGroupingPass(HashSet<CkanModule> unsearched,
803
                                                                   CkanModule firstModule)
804
        {
2✔
805
            var searching = new List<CkanModule> { firstModule };
2✔
806
            unsearched.ExceptWith(searching);
2✔
807
            var found = searching.ToHashSet();
2✔
808
            // Breadth first search to find all modules with any URLs in common, transitively
809
            while (searching.Count > 0)
2✔
810
            {
2✔
811
                var origin = searching.First();
2✔
812
                searching.Remove(origin);
2✔
813
                var neighbors = origin.download
2✔
814
                    ?.SelectMany(dlUri => unsearched.Where(other => other.download != null && other.download.Contains(dlUri)))
2!
815
                     .ToHashSet();
816
                if (neighbors is not null)
2!
817
                {
2✔
818
                    unsearched.ExceptWith(neighbors);
2✔
819
                    searching.AddRange(neighbors);
2✔
820
                    found.UnionWith(neighbors);
2✔
821
                }
2✔
822
            }
2✔
823
            return found;
2✔
824
        }
2✔
825

826
        /// <summary>
827
        /// Find the minimum and maximum mod versions and compatible game versions
828
        /// for a list of modules (presumably different versions of the same mod).
829
        /// </summary>
830
        /// <param name="modVersions">The modules to inspect</param>
831
        /// <param name="minMod">Return parameter for the lowest  mod  version</param>
832
        /// <param name="maxMod">Return parameter for the highest mod  version</param>
833
        /// <param name="minGame">Return parameter for the lowest  game version</param>
834
        /// <param name="maxGame">Return parameter for the highest game version</param>
835
        public static void GetMinMaxVersions(
836
                IEnumerable<CkanModule?> modVersions,
837
                out ModuleVersion? minMod,  out ModuleVersion? maxMod,
838
                out GameVersion?   minGame, out GameVersion?   maxGame)
839
        {
2✔
840
            minMod  = maxMod  = null;
2✔
841
            minGame = maxGame = null;
2✔
842
            foreach (var mod in modVersions.OfType<CkanModule>())
5✔
843
            {
2✔
844
                if (minMod == null || minMod > mod.version)
2✔
845
                {
2✔
846
                    minMod = mod.version;
2✔
847
                }
2✔
848
                if (maxMod == null || maxMod < mod.version)
2!
849
                {
2✔
850
                    maxMod = mod.version;
2✔
851
                }
2✔
852
                GameVersion relMin = mod.EarliestCompatibleGameVersion();
2✔
853
                GameVersion relMax = mod.LatestCompatibleGameVersion();
2✔
854
                if (minGame == null || (!minGame.IsAny && (minGame > relMin || relMin.IsAny)))
2✔
855
                {
2✔
856
                    minGame = relMin;
2✔
857
                }
2✔
858
                if (maxGame == null || (!maxGame.IsAny && (maxGame < relMax || relMax.IsAny)))
2✔
859
                {
2✔
860
                    maxGame = relMax;
2✔
861
                }
2✔
862
            }
2✔
863
        }
2✔
864
    }
865

866
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc