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

KSP-CKAN / CKAN / 15655379223

14 Jun 2025 07:26PM UTC coverage: 27.151% (-3.2%) from 30.327%
15655379223

push

github

HebaruSan
Merge #4392 Writethrough when saving files, add Netkan tests

3702 of 12085 branches covered (30.63%)

Branch coverage included in aggregate %.

19 of 32 new or added lines in 18 files covered. (59.38%)

6 existing lines in 6 files now uncovered.

8041 of 31165 relevant lines covered (25.8%)

0.53 hits per line

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

79.44
/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
        [DefaultValue(ModuleKind.package)]
50
        public ModuleKind kind = ModuleKind.package;
2✔
51

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

217
        #endregion
218

219
        #region Constructors
220

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

489
        #endregion
490

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

767
        private const double K = 1024;
768

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

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

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

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

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

865
}
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