1 /**
2  * Contains type definition for the selections file
3  *
4  * The selections file, commonly known by its file name
5  * `dub.selections.json`, is used by Dub to store resolved
6  * dependencies. Its purpose is identical to other package
7  * managers' lock file.
8  */
9 module dub.recipe.selection;
10 
11 import dub.dependency;
12 import dub.internal.vibecompat.inet.path : NativePath;
13 
14 import dub.internal.configy.Attributes;
15 import dub.internal.dyaml.stdsumtype;
16 
17 import std.exception;
18 
19 deprecated("Use either `Selections!1` or `SelectionsFile` instead")
20 public alias Selected = Selections!1;
21 
22 /**
23  * Top level type for `dub.selections.json`
24  *
25  * To support multiple version, we expose a `SumType` which
26  * contains the "real" version being parsed.
27  */
28 public struct SelectionsFile
29 {
30     /// Private alias to avoid repetition
31     private alias DataType = SumType!(Selections!0, Selections!1);
32 
33     /**
34      * Get the `fileVersion` of this selection file
35      *
36      * The `fileVersion` is always present, no matter the version.
37      * This is a convenience function that matches any version and allows
38      * one to retrieve it.
39      *
40      * Note that the `fileVersion` can be an unsupported version.
41      */
42     public uint fileVersion () const @safe pure nothrow @nogc
43     {
44         return this.content.match!((s) => s.fileVersion);
45     }
46 
47     /**
48      * The content of this selections file
49      *
50      * The underlying content can be accessed using
51      * `dub.internal.yaml.stdsumtype : match`, for example:
52      * ---
53      * SelectionsFile file = readSelectionsFile();
54      * file.content.match!(
55      *     (Selections!0 s) => logWarn("Unsupported version: %s", s.fileVersion),
56      *     (Selections!1 s) => logWarn("Old version (1), please upgrade!"),
57      *     (Selections!2 s) => logInfo("You are up to date"),
58      * );
59      * ---
60      */
61     public DataType content;
62 
63     /**
64      * Deserialize the selections file according to its version
65      *
66      * This will first deserialize the `fileVersion` only, and then
67      * the expected version if it is supported. Unsupported versions
68      * will be returned inside a `Selections!0` struct,
69      * which only contains a `fileVersion`.
70      */
71     public static SelectionsFile fromYAML (scope ConfigParser!SelectionsFile parser)
72     {
73         import dub.internal.configy.Read;
74 
75         static struct OnlyVersion { uint fileVersion; }
76 
77         auto vers = parseConfig!OnlyVersion(
78             CLIArgs.init, parser.node, StrictMode.Ignore);
79 
80         switch (vers.fileVersion) {
81         case 1:
82             return SelectionsFile(DataType(parser.parseAs!(Selections!1)));
83         default:
84             return SelectionsFile(DataType(Selections!0(vers.fileVersion)));
85         }
86     }
87 }
88 
89 /**
90  * A specific version of the selections file
91  *
92  * Currently, only two instantiations of this struct are possible:
93  * - `Selections!0` is an invalid/unsupported version;
94  * - `Selections!1` is the most widespread version;
95  */
96 public struct Selections (ushort Version)
97 {
98     ///
99     public uint fileVersion = Version;
100 
101     static if (Version == 0) { /* Invalid version */ }
102     else static if (Version == 1) {
103         /// The selected package and their matching versions
104         public SelectedDependency[string] versions;
105     }
106     else
107         static assert(false, "This version is not supported");
108 }
109 
110 
111 /// Wrapper around `SelectedDependency` to do deserialization but still provide
112 /// a `Dependency` object to client code.
113 private struct SelectedDependency
114 {
115     public Dependency actual;
116     alias actual this;
117 
118     /// Constructor, used in `fromYAML`
119     public this (inout(Dependency) dep) inout @safe pure nothrow @nogc
120     {
121         this.actual = dep;
122     }
123 
124     /// Allow external code to assign to this object as if it was a `Dependency`
125     public ref SelectedDependency opAssign (Dependency dep) return pure nothrow @nogc
126     {
127         this.actual = dep;
128         return this;
129     }
130 
131     /// Read a `Dependency` from the config file - Required to support both short and long form
132     static SelectedDependency fromYAML (scope ConfigParser!SelectedDependency p)
133     {
134         import dub.internal.dyaml.node;
135 
136         if (p.node.nodeID == NodeID.scalar)
137             return SelectedDependency(Dependency(Version(p.node.as!string)));
138 
139         auto d = p.parseAs!YAMLFormat;
140         if (d.path.length)
141             return SelectedDependency(Dependency(NativePath(d.path)));
142         else
143         {
144             assert(d.version_.length);
145             if (d.repository.length)
146                 return SelectedDependency(Dependency(Repository(d.repository, d.version_)));
147             return SelectedDependency(Dependency(Version(d.version_)));
148         }
149     }
150 
151 	/// In-file representation of a dependency as permitted in `dub.selections.json`
152 	private struct YAMLFormat
153 	{
154 		@Optional @Name("version") string version_;
155 		@Optional string path;
156 		@Optional string repository;
157 
158 		public void validate () const scope @safe pure
159 		{
160 			enforce(this.version_.length || this.path.length || this.repository.length,
161 				"Need to provide a version string, or an object with one of the following fields: `version`, `path`, or `repository`");
162 			enforce(!this.path.length || !this.repository.length,
163 				"Cannot provide a `path` dependency if a repository dependency is used");
164 			enforce(!this.path.length || !this.version_.length,
165 				"Cannot provide a `path` dependency if a `version` dependency is used");
166 			enforce(!this.repository.length || this.version_.length,
167 				"Cannot provide a `repository` dependency without a `version`");
168 		}
169 	}
170 }
171 
172 // Ensure we can read all type of dependencies
173 unittest
174 {
175     import dub.internal.configy.Read : parseConfigString;
176 
177     immutable string content = `{
178     "fileVersion": 1,
179     "versions": {
180         "simple": "1.5.6",
181         "branch": "~master",
182         "branch2": "~main",
183         "path": { "path": "../some/where" },
184         "repository": { "repository": "git+https://github.com/dlang/dub", "version": "123456123456123456" }
185     }
186 }`;
187 
188     auto file = parseConfigString!SelectionsFile(content, "/dev/null");
189     assert(file.fileVersion == 1);
190     auto s = file.content.match!(
191         (Selections!1 s) => s,
192         (s) { assert(0); return Selections!(1).init; },
193     );
194     assert(s.versions.length == 5);
195     assert(s.versions["simple"]     == Dependency(Version("1.5.6")));
196     assert(s.versions["branch"]     == Dependency(Version("~master")));
197     assert(s.versions["branch2"]    == Dependency(Version("~main")));
198     assert(s.versions["path"]       == Dependency(NativePath("../some/where")));
199     assert(s.versions["repository"] == Dependency(Repository("git+https://github.com/dlang/dub", "123456123456123456")));
200 }
201 
202 // Test reading an unsupported version
203 unittest
204 {
205     import dub.internal.configy.Read : parseConfigString;
206 
207     immutable string content = `{"fileVersion": 9999, "thisis": "notrecognized"}`;
208     auto s = parseConfigString!SelectionsFile(content, "/dev/null");
209     assert(s.fileVersion == 9999);
210 }