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      * Whether this dub.selections.json can be inherited by nested projects
49      * without local dub.selections.json
50      */
51     public bool inheritable () const @safe pure nothrow @nogc
52     {
53         return this.content.match!(
54             (const Selections!0 _) => false,
55             (const Selections!1 s) => s.inheritable,
56         );
57     }
58 
59     /**
60      * The content of this selections file
61      *
62      * The underlying content can be accessed using
63      * `dub.internal.yaml.stdsumtype : match`, for example:
64      * ---
65      * SelectionsFile file = readSelectionsFile();
66      * file.content.match!(
67      *     (Selections!0 s) => logWarn("Unsupported version: %s", s.fileVersion),
68      *     (Selections!1 s) => logWarn("Old version (1), please upgrade!"),
69      *     (Selections!2 s) => logInfo("You are up to date"),
70      * );
71      * ---
72      */
73     public DataType content;
74 
75     /**
76      * Deserialize the selections file according to its version
77      *
78      * This will first deserialize the `fileVersion` only, and then
79      * the expected version if it is supported. Unsupported versions
80      * will be returned inside a `Selections!0` struct,
81      * which only contains a `fileVersion`.
82      */
83     public static SelectionsFile fromYAML (scope ConfigParser!SelectionsFile parser)
84     {
85         import dub.internal.configy.Read;
86 
87         static struct OnlyVersion { uint fileVersion; }
88 
89         auto vers = parseConfig!OnlyVersion(
90             CLIArgs.init, parser.node, StrictMode.Ignore);
91 
92         switch (vers.fileVersion) {
93         case 1:
94             return SelectionsFile(DataType(parser.parseAs!(Selections!1)));
95         default:
96             return SelectionsFile(DataType(Selections!0(vers.fileVersion)));
97         }
98     }
99 }
100 
101 /**
102  * A specific version of the selections file
103  *
104  * Currently, only two instantiations of this struct are possible:
105  * - `Selections!0` is an invalid/unsupported version;
106  * - `Selections!1` is the most widespread version;
107  */
108 public struct Selections (ushort Version)
109 {
110     ///
111     public uint fileVersion = Version;
112 
113     static if (Version == 0) { /* Invalid version */ }
114     else static if (Version == 1) {
115         /// The selected package and their matching versions
116         public SelectedDependency[string] versions;
117 
118         /// Whether this dub.selections.json can be inherited by nested projects
119         /// without local dub.selections.json
120         @Optional public bool inheritable;
121     }
122     else
123         static assert(false, "This version is not supported");
124 }
125 
126 
127 /// Wrapper around `SelectedDependency` to do deserialization but still provide
128 /// a `Dependency` object to client code.
129 private struct SelectedDependency
130 {
131     public Dependency actual;
132     alias actual this;
133 
134     /// Constructor, used in `fromYAML`
135     public this (inout(Dependency) dep) inout @safe pure nothrow @nogc
136     {
137         this.actual = dep;
138     }
139 
140     /// Allow external code to assign to this object as if it was a `Dependency`
141     public ref SelectedDependency opAssign (Dependency dep) return pure nothrow @nogc
142     {
143         this.actual = dep;
144         return this;
145     }
146 
147     /// Read a `Dependency` from the config file - Required to support both short and long form
148     static SelectedDependency fromYAML (scope ConfigParser!SelectedDependency p)
149     {
150         import dub.internal.dyaml.node;
151 
152         if (p.node.nodeID == NodeID.scalar)
153             return SelectedDependency(Dependency(Version(p.node.as!string)));
154 
155         auto d = p.parseAs!YAMLFormat;
156         if (d.path.length)
157             return SelectedDependency(Dependency(NativePath(d.path)));
158         else
159         {
160             assert(d.version_.length);
161             if (d.repository.length)
162                 return SelectedDependency(Dependency(Repository(d.repository, d.version_)));
163             return SelectedDependency(Dependency(Version(d.version_)));
164         }
165     }
166 
167 	/// In-file representation of a dependency as permitted in `dub.selections.json`
168 	private struct YAMLFormat
169 	{
170 		@Optional @Name("version") string version_;
171 		@Optional string path;
172 		@Optional string repository;
173 
174 		public void validate () const scope @safe pure
175 		{
176 			enforce(this.version_.length || this.path.length || this.repository.length,
177 				"Need to provide a version string, or an object with one of the following fields: `version`, `path`, or `repository`");
178 			enforce(!this.path.length || !this.repository.length,
179 				"Cannot provide a `path` dependency if a repository dependency is used");
180 			enforce(!this.path.length || !this.version_.length,
181 				"Cannot provide a `path` dependency if a `version` dependency is used");
182 			enforce(!this.repository.length || this.version_.length,
183 				"Cannot provide a `repository` dependency without a `version`");
184 		}
185 	}
186 }
187 
188 // Ensure we can read all type of dependencies
189 unittest
190 {
191     import dub.internal.configy.Read : parseConfigString;
192 
193     immutable string content = `{
194     "fileVersion": 1,
195     "versions": {
196         "simple": "1.5.6",
197         "branch": "~master",
198         "branch2": "~main",
199         "path": { "path": "../some/where" },
200         "repository": { "repository": "git+https://github.com/dlang/dub", "version": "123456123456123456" }
201     }
202 }`;
203 
204     auto file = parseConfigString!SelectionsFile(content, "/dev/null");
205     assert(file.fileVersion == 1);
206     auto s = file.content.match!(
207         (Selections!1 s) => s,
208         (s) { assert(0); return Selections!(1).init; },
209     );
210     assert(!s.inheritable);
211     assert(s.versions.length == 5);
212     assert(s.versions["simple"]     == Dependency(Version("1.5.6")));
213     assert(s.versions["branch"]     == Dependency(Version("~master")));
214     assert(s.versions["branch2"]    == Dependency(Version("~main")));
215     assert(s.versions["path"]       == Dependency(NativePath("../some/where")));
216     assert(s.versions["repository"] == Dependency(Repository("git+https://github.com/dlang/dub", "123456123456123456")));
217 }
218 
219 // with optional `inheritable` Boolean
220 unittest
221 {
222     import dub.internal.configy.Read : parseConfigString;
223 
224     immutable string content = `{
225     "fileVersion": 1,
226     "inheritable": true,
227     "versions": {
228         "simple": "1.5.6",
229     }
230 }`;
231 
232     auto s = parseConfigString!SelectionsFile(content, "/dev/null");
233     assert(s.inheritable);
234 }
235 
236 // Test reading an unsupported version
237 unittest
238 {
239     import dub.internal.configy.Read : parseConfigString;
240 
241     immutable string content = `{"fileVersion": 9999, "thisis": "notrecognized"}`;
242     auto s = parseConfigString!SelectionsFile(content, "/dev/null");
243     assert(s.fileVersion == 9999);
244 }